mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-16 16:30:41 +01:00
Compare commits
17 Commits
feat/user-
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f057b3709 | ||
|
|
33c3d94bee | ||
|
|
67878a01b2 | ||
|
|
c9ae10b1c0 | ||
|
|
84a802edba | ||
|
|
f78bd492d8 | ||
|
|
7e728824f5 | ||
|
|
d2063281e2 | ||
|
|
75e6ceb4bb | ||
|
|
7d3273c423 | ||
|
|
a88cc79ef9 | ||
|
|
e424082835 | ||
|
|
2afed07b5b | ||
|
|
1c0dc018e0 | ||
|
|
bb2511ce53 | ||
|
|
33d22c8b59 | ||
|
|
c1b9de0c8a |
7
.claude/opencode.json
Normal file
7
.claude/opencode.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": true,
|
||||
"experimental": {
|
||||
"disable_paste_summary": true
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ Applies to everything in the repo — code, config, workflows.
|
||||
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
|
||||
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
|
||||
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
|
||||
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
|
||||
|
||||
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).
|
||||
|
||||
16
.claude/rules/go-contrib.md
Normal file
16
.claude/rules/go-contrib.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.go"
|
||||
---
|
||||
|
||||
# Contribution guidelines
|
||||
|
||||
- When making Go changes, always ensure they follow the contributing guildelines in [`docs/contributing/go/`](../../docs/contributing/go/).
|
||||
- Look for existing patterns in the codebase for any change before implementing the changes.
|
||||
- If any API contract is modified, generate the OpenAPI specs with `make gen-openapi-specs`.
|
||||
- Always keep the OpenAPI spec generated in a separate commit, so the whole commit can be dropped in case of conflicts during merge. Do not try to resolve conflict in generated files, instead just generate them again.
|
||||
- Avoid breaking function calls unncessarily into multilines for couple of arguments.
|
||||
- Try to keep most computational only logic in types package itself related to a domain type, use modules as the orchestraction layer cordinating different layers and all db queries in store layer. Check the serviceaccount modules for inspiration when confused.
|
||||
- When defining types, keep the structure of file to have any constants and variables first, then exported types and exported methods and then finally the unexported types and methods.
|
||||
- Never import types or other modules in migration files, duplicate the required type or method to keep migration free from changes.
|
||||
- Always run the gofmt tool for formating beforing commiting any changes.
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
- **Follow the template** (`.github/pull_request_template.md`): fill in its headings (Description / Issues closed by this PR / Screenshots / Additional Information). Don't add sections the template doesn't have.
|
||||
- **Keep only the headings that apply.** Delete every heading that has nothing under it, along with its `<!--...-->` placeholder comment. The body must never contain an empty heading — if only Description applies, the body has exactly that one heading.
|
||||
- **Keep the description concise and human-readable.** A few plain bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate.
|
||||
- **Keep the description concise and human-readable.** A few non repetitive bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate and not the user agent conversation details.
|
||||
- **Reference issues with `Closes #issue-number`** under "Issues closed by this PR" so they auto-close on merge. This goes in the PR description only — never in commit messages.
|
||||
- **Breaking changes can be added in additional information section** if any.
|
||||
- **AI assistance in commits may optionally be disclosed with an `Assisted-by:` trailer** naming the model (e.g. `Assisted-by: Claude Opus 4.5`) — do NOT use a `Co-authored-by:` trailer for this.
|
||||
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
|
||||
- **Use convensional commit format** for commits and PR title.
|
||||
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.
|
||||
|
||||
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -15,6 +15,10 @@
|
||||
.github @therealpandey
|
||||
go.mod @therealpandey
|
||||
|
||||
# Security
|
||||
|
||||
/SECURITY.md @therealpandey
|
||||
|
||||
# Scaffold Owners
|
||||
|
||||
/pkg/config/ @therealpandey
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -232,3 +232,4 @@ pyrightconfig.json
|
||||
# dev
|
||||
.dev/
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
34
Makefile
34
Makefile
@@ -81,10 +81,13 @@ devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
|
||||
##############################################################
|
||||
# go commands
|
||||
##############################################################
|
||||
SIGNOZ_SQLSTORE_SQLITE_PATH ?= signoz.db
|
||||
SIGNOZ_APISERVER_ADDRESS ?= 0.0.0.0:8080
|
||||
|
||||
.PHONY: go-run-enterprise
|
||||
go-run-enterprise: ## Runs the enterprise go backend server
|
||||
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
|
||||
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
|
||||
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
|
||||
SIGNOZ_WEB_ENABLED=false \
|
||||
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
|
||||
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
|
||||
@@ -101,7 +104,7 @@ go-test: ## Runs go unit tests
|
||||
.PHONY: go-run-community
|
||||
go-run-community: ## Runs the community go backend server
|
||||
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
|
||||
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
|
||||
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
|
||||
SIGNOZ_WEB_ENABLED=false \
|
||||
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
|
||||
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
|
||||
@@ -111,6 +114,28 @@ go-run-community: ## Runs the community go backend server
|
||||
go run -race \
|
||||
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server
|
||||
|
||||
.PHONY: go-stop
|
||||
go-stop: ## Stops the go backend server listening on SIGNOZ_APISERVER_ADDRESS, waiting for it to release every port it holds
|
||||
@PORT=$(lastword $(subst :, ,$(SIGNOZ_APISERVER_ADDRESS))); \
|
||||
PIDS=$$(lsof -ti tcp:$$PORT); \
|
||||
if [ -z "$$PIDS" ]; then \
|
||||
echo "No signoz server running on port $$PORT."; \
|
||||
echo "If it's running on a different port, rerun as: make go-stop SIGNOZ_APISERVER_ADDRESS=host:port"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
kill $$PIDS 2>/dev/null; \
|
||||
for i in $$(seq 1 10); do \
|
||||
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
|
||||
[ -z "$$alive" ] && break; \
|
||||
sleep 1; \
|
||||
done; \
|
||||
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
|
||||
if [ -n "$$alive" ]; then \
|
||||
echo "Graceful shutdown did not finish in 10s, sending SIGKILL to $$alive"; \
|
||||
kill -9 $$alive 2>/dev/null; \
|
||||
fi; \
|
||||
echo "Stopped signoz server on port $$PORT (pid $$PIDS)"
|
||||
|
||||
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
|
||||
go-build-community: ## Builds the go backend server for community
|
||||
go-build-community: $(GO_BUILD_ARCHS_COMMUNITY)
|
||||
@@ -241,3 +266,8 @@ semconv-generate: ## Regenerate semantic-convention families for Go and TypeScri
|
||||
gen-mocks:
|
||||
@echo ">> Generating mocks"
|
||||
@mockery --config .mockery.yml
|
||||
|
||||
.PHONY: gen-openapi-specs
|
||||
gen-openapi-specs:
|
||||
@go run cmd/enterprise/*.go generate openapi
|
||||
cd frontend && pnpm generate:api && cd -
|
||||
|
||||
17
SECURITY.md
17
SECURITY.md
@@ -1,17 +1,26 @@
|
||||
# Security Policy
|
||||
|
||||
SigNoz is looking forward to working with security researchers across the world to keep SigNoz and our users safe. If you have found an issue in our systems/applications, please reach out to us.
|
||||
SigNoz is looking forward to working with security researchers across the world to keep SigNoz and our users safe. If you have found an issue in our systems/applications, please report it to us privately.
|
||||
|
||||
## Supported Versions
|
||||
We always recommend using the latest version of SigNoz to ensure you get all security updates
|
||||
|
||||
We always recommend using the latest version of SigNoz to ensure you get all security updates.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you have found a security vulnerability within SigNoz, please let us know right away. We'll try and fix the problem as soon as possible.
|
||||
|
||||
**Do not report vulnerabilities using public GitHub issues**. Instead, email <security@signoz.io> with a detailed account of the issue. Please submit one issue per email, this helps us triage vulnerabilities.
|
||||
**Do not report vulnerabilities using public GitHub issues, discussions, or pull requests.**
|
||||
|
||||
Once we've received your email we'll keep you updated as we fix the vulnerability.
|
||||
Instead, report it privately through GitHub's private vulnerability reporting:
|
||||
|
||||
1. Go to the [**Security** tab](https://github.com/SigNoz/signoz/security) of this repository.
|
||||
2. Click **Report a vulnerability**, or use [this link](https://github.com/SigNoz/signoz/security/advisories/new).
|
||||
3. Describe the issue with as much detail as you can — affected version, impact, and steps to reproduce help us triage faster. Please submit one report per vulnerability.
|
||||
|
||||
This opens a private advisory visible only to you and the SigNoz maintainers. We'll respond there, keep you updated as we work on a fix, and coordinate disclosure. If the report is valid we'll credit you on the published advisory and request a CVE.
|
||||
|
||||
If you're unable to use GitHub's private reporting, you can email <security@signoz.io> instead.
|
||||
|
||||
## Thanks
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@ sqlstore:
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
# The TCP address the API server listens on, in the form "host:port".
|
||||
address: 0.0.0.0:8080
|
||||
# Maximum duration for reading an entire request, including the body.
|
||||
read_timeout: 60s
|
||||
# Keep at 0; any value cuts off streaming endpoints (livetail, SSE, export_raw_data).
|
||||
write_timeout: 0
|
||||
timeout:
|
||||
# Default request timeout.
|
||||
default: 60s
|
||||
|
||||
@@ -3137,6 +3137,67 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
legend:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
limitBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
|
||||
name:
|
||||
type: string
|
||||
offset:
|
||||
type: integer
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
secondaryAggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
enum:
|
||||
- traces
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/TelemetrytypesSource'
|
||||
stepInterval:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Step'
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
DashboardtypesAxes:
|
||||
properties:
|
||||
isLogScale:
|
||||
@@ -4040,6 +4101,7 @@ components:
|
||||
DashboardtypesQueryPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
|
||||
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
@@ -4049,6 +4111,7 @@ components:
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
|
||||
@@ -4058,12 +4121,25 @@ components:
|
||||
DashboardtypesQueryPluginKind:
|
||||
enum:
|
||||
- signoz/BuilderQuery
|
||||
- signoz/AIBuilderQuery
|
||||
- signoz/CompositeQuery
|
||||
- signoz/Formula
|
||||
- signoz/PromQLQuery
|
||||
- signoz/ClickHouseSQL
|
||||
- signoz/TraceOperator
|
||||
type: string
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/AIBuilderQuery
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -20308,9 +20384,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- role:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- role:read
|
||||
- ADMIN
|
||||
summary: Get users by role id
|
||||
tags:
|
||||
- users
|
||||
@@ -24872,11 +24948,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
summary: Create user role
|
||||
tags:
|
||||
- users
|
||||
@@ -24926,11 +25000,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:detach
|
||||
- role:detach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:detach
|
||||
- role:detach
|
||||
- ADMIN
|
||||
summary: Delete user role
|
||||
tags:
|
||||
- users
|
||||
@@ -24991,9 +25063,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:read
|
||||
- ADMIN
|
||||
summary: Get user role
|
||||
tags:
|
||||
- users
|
||||
@@ -25039,9 +25111,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:list
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:list
|
||||
- ADMIN
|
||||
summary: List users v2
|
||||
tags:
|
||||
- users
|
||||
@@ -25101,13 +25173,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:create
|
||||
- user:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:create
|
||||
- user:attach
|
||||
- role:attach
|
||||
- ADMIN
|
||||
summary: Create user
|
||||
tags:
|
||||
- users
|
||||
@@ -25151,9 +25219,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:delete
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:delete
|
||||
- ADMIN
|
||||
summary: Delete user
|
||||
tags:
|
||||
- users
|
||||
@@ -25208,9 +25276,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:read
|
||||
- ADMIN
|
||||
summary: Get user by user id
|
||||
tags:
|
||||
- users
|
||||
@@ -25264,9 +25332,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:update
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:update
|
||||
- ADMIN
|
||||
summary: Update user v2
|
||||
tags:
|
||||
- users
|
||||
@@ -25322,9 +25390,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- factor-password:list
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- factor-password:list
|
||||
- ADMIN
|
||||
summary: Get reset password token for a user
|
||||
tags:
|
||||
- users
|
||||
@@ -25387,11 +25455,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- factor-password:create
|
||||
- user:attach
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- factor-password:create
|
||||
- user:attach
|
||||
- ADMIN
|
||||
summary: Create or regenerate reset password token for a user
|
||||
tags:
|
||||
- users
|
||||
@@ -25449,9 +25515,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- user:read
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- user:read
|
||||
- ADMIN
|
||||
summary: Get user roles
|
||||
tags:
|
||||
- users
|
||||
|
||||
@@ -83,7 +83,13 @@ This command:
|
||||
|
||||
You should see: `{"status":"ok"}`
|
||||
|
||||
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default
|
||||
3. Stop it when you're done:
|
||||
```bash
|
||||
make go-stop
|
||||
```
|
||||
|
||||
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default. You can configure this using `apiserver.address` configuration option. See
|
||||
> [running more than one instance](#how-do-i-run-more-than-one-instance) if you need that for agentic testing.
|
||||
|
||||
### 4. Setting up the Frontend
|
||||
|
||||
@@ -119,6 +125,36 @@ To verify everything is working correctly:
|
||||
3. **Check Backend**: `curl http://localhost:8080/api/v1/health` (should return `{"status":"ok"}`)
|
||||
4. **Check Frontend**: Open `http://localhost:3301` in your browser
|
||||
|
||||
## How do I run more than one instance?
|
||||
|
||||
Handy when you keep several branches checked out as separate git worktrees. Every port
|
||||
and path below is read from the environment, so set them on the `make` call:
|
||||
|
||||
```bash
|
||||
SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081 \
|
||||
SIGNOZ_SQLSTORE_SQLITE_PATH=/path/to/main/sqlite.db \
|
||||
SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT=9091 \
|
||||
make go-run-community
|
||||
```
|
||||
|
||||
| Variable | Default | Why you'd change it |
|
||||
| --- | --- | --- |
|
||||
| `SIGNOZ_APISERVER_ADDRESS` | `0.0.0.0:8080` | Address the API server listens on |
|
||||
| `SIGNOZ_SQLSTORE_SQLITE_PATH` | `signoz.db` in worktree | To reuse same database |
|
||||
| `SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT` | `9090` | Bound by the Prometheus metrics exporter on startup |
|
||||
|
||||
Point the frontend at whichever backend you want, in `frontend/.env`:
|
||||
|
||||
```env
|
||||
VITE_FRONTEND_API_ENDPOINT=http://localhost:8081
|
||||
```
|
||||
|
||||
Stop an instance using the address it was started on:
|
||||
|
||||
```bash
|
||||
make go-stop SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081
|
||||
```
|
||||
|
||||
## How to send test data?
|
||||
|
||||
You can now send telemetry data to your local SigNoz instance:
|
||||
|
||||
@@ -121,7 +121,7 @@ The pieces:
|
||||
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
|
||||
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
|
||||
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). If the target list is optional in the payload (e.g. `userRoles` at user creation), set `OptionalTargets`: when no target ids resolve, the attach is vacuous and the def is skipped entirely — without it, the empty-id contract would check collection-level access on the target. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
|
||||
|
||||
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.
|
||||
|
||||
|
||||
@@ -3,56 +3,29 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
"github.com/rs/cors"
|
||||
"github.com/soheilhy/cmux"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
)
|
||||
|
||||
// Server runs HTTP, Mux and a grpc server
|
||||
// Server runs auxiliary servers (opamp) alongside the signoz apiserver
|
||||
type Server struct {
|
||||
config signoz.Config
|
||||
signoz *signoz.SigNoz
|
||||
|
||||
// public http router
|
||||
httpConn net.Listener
|
||||
httpServer *http.Server
|
||||
httpHostPort string
|
||||
|
||||
opampServer *opamp.Server
|
||||
|
||||
// Usage manager
|
||||
usageManager *usage.Manager
|
||||
|
||||
unavailableChannel chan healthcheck.Status
|
||||
}
|
||||
|
||||
// NewServer creates and initializes Server
|
||||
@@ -127,57 +100,11 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
config: config,
|
||||
signoz: signoz,
|
||||
httpHostPort: baseconst.HTTPHostPort,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
usageManager: usageManager,
|
||||
}
|
||||
|
||||
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.httpServer = httpServer
|
||||
|
||||
s.opampServer = opamp.InitializeServer(
|
||||
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HealthCheckStatus returns health check status channel a client can subscribe to
|
||||
func (s Server) HealthCheckStatus() chan healthcheck.Status {
|
||||
return s.unavailableChannel
|
||||
}
|
||||
|
||||
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
|
||||
r := baseapp.NewRouter()
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
|
||||
|
||||
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(otelmux.Middleware(
|
||||
"apiserver",
|
||||
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
|
||||
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
|
||||
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
|
||||
otelmux.WithFilter(func(r *http.Request) bool {
|
||||
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
|
||||
}),
|
||||
))
|
||||
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
|
||||
s.config.APIServer.Timeout.ExcludedRoutes,
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
// Register the legacy query-service routes on the apiserver router. The
|
||||
// apiserver owns the HTTP server and applies the middleware chain at serve
|
||||
// time, so these routes get the same treatment as the apiserver routes.
|
||||
r := signoz.APIServer.Router()
|
||||
am := middleware.NewAuthZ(signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter, signoz.Authz)
|
||||
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
@@ -188,107 +115,29 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
err := s.signoz.APIServer.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
s := &Server{
|
||||
usageManager: usageManager,
|
||||
}
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
s.opampServer = opamp.InitializeServer(
|
||||
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
|
||||
)
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
handler = handlers.CompressHandler(handler)
|
||||
|
||||
err = web.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routePrefix := s.config.Global.ExternalPath()
|
||||
if routePrefix != "" {
|
||||
prefixed := http.StripPrefix(routePrefix, handler)
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
|
||||
r.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
prefixed.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
}, nil
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// initListeners initialises listeners of the server
|
||||
func (s *Server) initListeners() error {
|
||||
// listen on public port
|
||||
var err error
|
||||
publicHostPort := s.httpHostPort
|
||||
if publicHostPort == "" {
|
||||
return fmt.Errorf("baseconst.HTTPHostPort is required")
|
||||
}
|
||||
|
||||
s.httpConn, err = net.Listen("tcp", publicHostPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
// Start starts the opamp websocket server. The HTTP API server is started by
|
||||
// the signoz registry.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
|
||||
if err := s.opampServer.Start(baseconst.OpAmpWsEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var httpPort int
|
||||
if port, err := utils.GetPort(s.httpConn.Addr()); err == nil {
|
||||
httpPort = port
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting HTTP server", "port", httpPort, "addr", s.httpHostPort)
|
||||
|
||||
switch err := s.httpServer.Serve(s.httpConn); err {
|
||||
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
|
||||
// normal exit, nothing to do
|
||||
default:
|
||||
slog.Error("Could not start HTTP server", errors.Attr(err))
|
||||
}
|
||||
s.unavailableChannel <- healthcheck.Unavailable
|
||||
}()
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
|
||||
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
|
||||
if err != nil {
|
||||
slog.Error("opamp ws server failed to start", errors.Attr(err))
|
||||
s.unavailableChannel <- healthcheck.Unavailable
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if s.httpServer != nil {
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.opampServer.Stop()
|
||||
|
||||
// stop usage manager
|
||||
|
||||
@@ -3952,123 +3952,10 @@ export interface DashboardGridLayoutSpecDTO {
|
||||
repeatVariable?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isLogScale?: boolean;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMax?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMin?: number | null;
|
||||
export enum DashboardtypesAIBuilderQuerySpecDTOSignal {
|
||||
traces = 'traces',
|
||||
}
|
||||
|
||||
export enum DashboardtypesPrecisionOptionDTO {
|
||||
NUMBER_0 = '0',
|
||||
NUMBER_1 = '1',
|
||||
NUMBER_2 = '2',
|
||||
NUMBER_3 = '3',
|
||||
NUMBER_4 = '4',
|
||||
full = 'full',
|
||||
}
|
||||
export interface DashboardtypesPanelFormattingDTO {
|
||||
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesLegendModeDTO {
|
||||
list = 'list',
|
||||
}
|
||||
export enum DashboardtypesLegendPositionDTO {
|
||||
bottom = 'bottom',
|
||||
right = 'right',
|
||||
}
|
||||
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesLegendDTOCustomColors =
|
||||
DashboardtypesLegendDTOCustomColorsAnyOf | null;
|
||||
|
||||
export interface DashboardtypesLegendDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
customColors?: DashboardtypesLegendDTOCustomColors;
|
||||
mode?: DashboardtypesLegendModeDTO;
|
||||
position?: DashboardtypesLegendPositionDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesThresholdWithLabelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesTimePreferenceDTO {
|
||||
global_time = 'global_time',
|
||||
last_5_min = 'last_5_min',
|
||||
last_15_min = 'last_15_min',
|
||||
last_30_min = 'last_30_min',
|
||||
last_1_hr = 'last_1_hr',
|
||||
last_6_hr = 'last_6_hr',
|
||||
last_1_day = 'last_1_day',
|
||||
last_3_days = 'last_3_days',
|
||||
last_1_week = 'last_1_week',
|
||||
last_1_month = 'last_1_month',
|
||||
}
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
stackedBarChart?: boolean;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesBarChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBasicVisualizationDTO {
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5LogAggregationDTO {
|
||||
export interface Querybuildertypesv5TraceAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4260,13 +4147,201 @@ export interface TelemetrytypesTelemetryFieldKeyDTO {
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
|
||||
logs = 'logs',
|
||||
}
|
||||
export enum TelemetrytypesSourceDTO {
|
||||
meter = 'meter',
|
||||
'' = '',
|
||||
}
|
||||
export interface DashboardtypesAIBuilderQuerySpecDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
cursor?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
functions?: Querybuildertypesv5FunctionDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
|
||||
having?: Querybuildertypesv5HavingDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
legend?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
limit?: number;
|
||||
limitBy?: Querybuildertypesv5LimitByDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
secondaryAggregations?: Querybuildertypesv5SecondaryAggregationDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
selectFields?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @enum traces
|
||||
* @type string
|
||||
*/
|
||||
signal: DashboardtypesAIBuilderQuerySpecDTOSignal;
|
||||
source?: TelemetrytypesSourceDTO;
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesAxesDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isLogScale?: boolean;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMax?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
softMin?: number | null;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPrecisionOptionDTO {
|
||||
NUMBER_0 = '0',
|
||||
NUMBER_1 = '1',
|
||||
NUMBER_2 = '2',
|
||||
NUMBER_3 = '3',
|
||||
NUMBER_4 = '4',
|
||||
full = 'full',
|
||||
}
|
||||
export interface DashboardtypesPanelFormattingDTO {
|
||||
decimalPrecision?: DashboardtypesPrecisionOptionDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesLegendModeDTO {
|
||||
list = 'list',
|
||||
}
|
||||
export enum DashboardtypesLegendPositionDTO {
|
||||
bottom = 'bottom',
|
||||
right = 'right',
|
||||
}
|
||||
export type DashboardtypesLegendDTOCustomColorsAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesLegendDTOCustomColors =
|
||||
DashboardtypesLegendDTOCustomColorsAnyOf | null;
|
||||
|
||||
export interface DashboardtypesLegendDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
customColors?: DashboardtypesLegendDTOCustomColors;
|
||||
mode?: DashboardtypesLegendModeDTO;
|
||||
position?: DashboardtypesLegendPositionDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesThresholdWithLabelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export enum DashboardtypesTimePreferenceDTO {
|
||||
global_time = 'global_time',
|
||||
last_5_min = 'last_5_min',
|
||||
last_15_min = 'last_15_min',
|
||||
last_30_min = 'last_30_min',
|
||||
last_1_hr = 'last_1_hr',
|
||||
last_6_hr = 'last_6_hr',
|
||||
last_1_day = 'last_1_day',
|
||||
last_3_days = 'last_3_days',
|
||||
last_1_week = 'last_1_week',
|
||||
last_1_month = 'last_1_month',
|
||||
}
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
fillSpans?: boolean;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
stackedBarChart?: boolean;
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBarChartPanelSpecDTO {
|
||||
axes?: DashboardtypesAxesDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
|
||||
visualization?: DashboardtypesBarChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesBasicVisualizationDTO {
|
||||
timePreference?: DashboardtypesTimePreferenceDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5LogAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTOSignal {
|
||||
logs = 'logs',
|
||||
}
|
||||
export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregationDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -4455,17 +4530,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TraceAggregationDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alias?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTOSignal {
|
||||
traces = 'traces',
|
||||
}
|
||||
@@ -4942,6 +5006,18 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesBuilderQuerySpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind {
|
||||
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
|
||||
}
|
||||
export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO {
|
||||
/**
|
||||
* @enum signoz/AIBuilderQuery
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind;
|
||||
spec: DashboardtypesAIBuilderQuerySpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind {
|
||||
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
|
||||
}
|
||||
@@ -5232,6 +5308,7 @@ export interface DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQu
|
||||
|
||||
export type DashboardtypesQueryPluginDTO =
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormulaDTO
|
||||
| DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQueryDTO
|
||||
@@ -5907,6 +5984,7 @@ export interface DashboardtypesPostablePublicDashboardDTO {
|
||||
|
||||
export enum DashboardtypesQueryPluginKindDTO {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
'signoz/AIBuilderQuery' = 'signoz/AIBuilderQuery',
|
||||
'signoz/CompositeQuery' = 'signoz/CompositeQuery',
|
||||
'signoz/Formula' = 'signoz/Formula',
|
||||
'signoz/PromQLQuery' = 'signoz/PromQLQuery',
|
||||
|
||||
@@ -5,8 +5,10 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DEFAULT_PANEL_TYPE } from '../constants';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
|
||||
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
|
||||
@@ -37,11 +37,13 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
|
||||
|
||||
compositeQuery.queries?.forEach((q) => {
|
||||
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
|
||||
if (q.type === 'builder_query') {
|
||||
if (q.type === 'builder_query' || q.type === 'builder_ai_query') {
|
||||
if (spec.name) {
|
||||
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
|
||||
spec as BuilderQuery,
|
||||
);
|
||||
builderQueries[spec.name] = {
|
||||
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
|
||||
builderQueryType: q.type,
|
||||
};
|
||||
// Both share the builder bucket; the AI variant rides on the query itself.
|
||||
builderQueryTypes[spec.name] = 'builder_query';
|
||||
}
|
||||
} else if (q.type === 'builder_formula') {
|
||||
|
||||
@@ -34,6 +34,32 @@ function builderPanel(name: string, expression: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
/** An AI panel: a CompositeQuery whose one envelope is tagged `builder_ai_query`. */
|
||||
function aiPanel(name: string, expression: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
display: { name },
|
||||
queries: [
|
||||
{
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_ai_query',
|
||||
spec: { name: 'A', signal: 'traces', filter: { expression } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function promqlPanel(name: string, query: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
@@ -60,6 +86,7 @@ describe('findVariableUsages', () => {
|
||||
p1: builderPanel('Panel One', "service IN $svc AND env = 'prod'"),
|
||||
p2: promqlPanel('Panel Two', 'up{s="$svc"}'),
|
||||
p3: builderPanel('Unrelated', "env = 'prod'"),
|
||||
p4: aiPanel('AI Panel', "service IN $svc AND kind = 'llm'"),
|
||||
},
|
||||
[
|
||||
variable({ name: 'svc', type: 'QUERY' }),
|
||||
@@ -75,7 +102,12 @@ describe('findVariableUsages', () => {
|
||||
it('finds panel (builder + promql) and variable usages, skipping unrelated ones', () => {
|
||||
const usages = findVariableUsages(dash, 'svc', 'rename', 'zone');
|
||||
const ids = usages.map((u) => u.id).sort();
|
||||
expect(ids).toStrictEqual(['panel:p1:0', 'panel:p2:0', 'variable:other:0']);
|
||||
expect(ids).toStrictEqual([
|
||||
'panel:p1:0',
|
||||
'panel:p2:0',
|
||||
'panel:p4:0',
|
||||
'variable:other:0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rewrites references for a rename across all kinds', () => {
|
||||
@@ -83,6 +115,7 @@ describe('findVariableUsages', () => {
|
||||
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
|
||||
expect(byId['panel:p1:0']).toBe("service IN $zone AND env = 'prod'");
|
||||
expect(byId['panel:p2:0']).toBe('up{s="$zone"}');
|
||||
expect(byId['panel:p4:0']).toBe("service IN $zone AND kind = 'llm'");
|
||||
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $zone');
|
||||
});
|
||||
|
||||
@@ -91,6 +124,8 @@ describe('findVariableUsages', () => {
|
||||
const byId = Object.fromEntries(usages.map((u) => [u.id, u.resultingText]));
|
||||
// Builder: the clause referencing $svc is dropped.
|
||||
expect(byId['panel:p1:0']).toBe("env = 'prod'");
|
||||
// The AI variant is a builder query too, so its clause is dropped the same way.
|
||||
expect(byId['panel:p4:0']).toBe("kind = 'llm'");
|
||||
// Raw PromQL + variable query: unchanged (user edits).
|
||||
expect(byId['panel:p2:0']).toBe('up{s="$svc"}');
|
||||
expect(byId['variable:other:0']).toBe('SELECT x WHERE s = $svc');
|
||||
@@ -109,6 +144,7 @@ describe('findApplyUsages', () => {
|
||||
has: builderPanel('Has it', 'k8s.pod.name IN $pod'),
|
||||
prom: promqlPanel('Prom', 'up'),
|
||||
promRef: promqlPanel('Prom Ref', 'up{pod="$pod"}'),
|
||||
ai: aiPanel('AI', ''),
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -125,6 +161,13 @@ describe('findApplyUsages', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the clause to a selected AI panel', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['ai']);
|
||||
const ai = usages.find((u) => u.id === 'panel:ai:0');
|
||||
expect(ai?.kind).toBe('builder');
|
||||
expect(ai?.resultingText).toBe('k8s.pod.name IN $pod');
|
||||
});
|
||||
|
||||
it('skips a selected panel that already carries the clause (idempotent)', () => {
|
||||
const usages = findApplyUsages(dash, 'k8s.pod.name', 'pod', 'pod', ['has']);
|
||||
expect(usages).toStrictEqual([]);
|
||||
@@ -182,6 +225,19 @@ describe('isVariableAppliedToAllPanels', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('is false when an AI panel is missing the reference', () => {
|
||||
const missing = dashboard(
|
||||
{
|
||||
b: builderPanel('B', 'k8s.pod.name IN $pod'),
|
||||
ai: aiPanel('AI', "kind = 'llm'"),
|
||||
},
|
||||
[],
|
||||
);
|
||||
expect(isVariableAppliedToAllPanels(missing, 'k8s.pod.name', 'pod')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false when any panel query is missing the reference', () => {
|
||||
const missing = dashboard(
|
||||
{
|
||||
|
||||
@@ -3,9 +3,10 @@ import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { isBuilderEnvelope } from '../../../queryV5/builderEnvelope';
|
||||
|
||||
function clauseFor(attribute: string, variableName: string): string {
|
||||
return `${attribute} IN $${variableName}`;
|
||||
}
|
||||
@@ -20,15 +21,15 @@ function forEachBuilderSpec(
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
const composite = plugin.spec as Querybuildertypesv5CompositeQueryDTO;
|
||||
(composite.queries ?? [])
|
||||
.filter((envelope) => envelope.type === 'builder_query')
|
||||
.forEach((envelope) => {
|
||||
const { spec } = envelope as Querybuildertypesv5QueryEnvelopeBuilderDTO;
|
||||
if (spec) {
|
||||
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
});
|
||||
} else if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
(composite.queries ?? []).filter(isBuilderEnvelope).forEach(({ spec }) => {
|
||||
if (spec) {
|
||||
fn(spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
plugin.kind === 'signoz/BuilderQuery' ||
|
||||
plugin.kind === 'signoz/AIBuilderQuery'
|
||||
) {
|
||||
fn(plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
textContainsVariableReference,
|
||||
} from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { isBuilderEnvelope } from '../../../queryV5/builderEnvelope';
|
||||
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
|
||||
import { dtoToFormModel } from '../variableAdapters';
|
||||
|
||||
@@ -52,7 +53,7 @@ function envelopeReferenceText(
|
||||
const spec = envelope.spec as
|
||||
| { query?: string; filter?: { expression?: string } }
|
||||
| undefined;
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const text = spec?.filter?.expression;
|
||||
return typeof text === 'string' ? { kind: 'builder', text } : null;
|
||||
}
|
||||
@@ -205,7 +206,7 @@ export function findApplyUsages(
|
||||
});
|
||||
};
|
||||
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
@@ -267,7 +268,7 @@ export function isVariableAppliedToAllPanels(
|
||||
return true;
|
||||
}
|
||||
return toQueryEnvelopes(queries).every((envelope) => {
|
||||
if (envelope.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(envelope)) {
|
||||
const spec = envelope.spec as
|
||||
| { filter?: { expression?: string } }
|
||||
| undefined;
|
||||
|
||||
@@ -10,6 +10,8 @@ interface ConfigActionsProps {
|
||||
/** The draft panel — its current query seeds the actions (e.g. Create alert). */
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/** Whether the builder holds an AI query — the alert builder can't seed from one. */
|
||||
isAIQuery: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,13 +22,15 @@ interface ConfigActionsProps {
|
||||
function ConfigActions({
|
||||
panel,
|
||||
panelId,
|
||||
isAIQuery,
|
||||
}: ConfigActionsProps): JSX.Element | null {
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
// Only kinds whose query can seed an alert offer this today; mirror the panel
|
||||
// menu's create-alert capability.
|
||||
if (!actions.createAlert) {
|
||||
// menu's create-alert capability. AI queries are excluded on top of that — the
|
||||
// alert builder has no AI tab, so it would seed a plain trace query instead.
|
||||
if (!actions.createAlert || isAIQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('ConfigActions', () => {
|
||||
it('offers "Create alert rule" for a create-alert-capable kind and seeds from the panel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const panel = makePanel('signoz/TimeSeriesPanel');
|
||||
render(<ConfigActions panel={panel} panelId="panel-1" />);
|
||||
render(<ConfigActions panel={panel} panelId="panel-1" isAIQuery={false} />);
|
||||
|
||||
const row = screen.getByTestId('panel-editor-v2-create-alert');
|
||||
expect(row).toHaveTextContent('Create alert');
|
||||
@@ -38,9 +38,28 @@ describe('ConfigActions', () => {
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(panel, 'panel-1');
|
||||
});
|
||||
|
||||
it('renders nothing for an AI query, even on a create-alert-capable kind', () => {
|
||||
const { container } = render(
|
||||
<ConfigActions
|
||||
panel={makePanel('signoz/TimeSeriesPanel')}
|
||||
panelId="panel-1"
|
||||
isAIQuery
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-create-alert'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing for a kind that cannot seed an alert', () => {
|
||||
const { container } = render(
|
||||
<ConfigActions panel={makePanel('signoz/TablePanel')} panelId="panel-1" />,
|
||||
<ConfigActions
|
||||
panel={makePanel('signoz/TablePanel')}
|
||||
panelId="panel-1"
|
||||
isAIQuery={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -29,6 +29,12 @@ interface ConfigPaneProps {
|
||||
* spec, because a new panel's spec has no query until staged.
|
||||
*/
|
||||
queryType: EQueryType;
|
||||
/**
|
||||
* Whether the builder holds an AI query. Read from the provider alongside `queryType`
|
||||
* for the same reason, and separate from it because AI-ness is a per-query tag rather
|
||||
* than a query type of its own.
|
||||
*/
|
||||
isAIQuery: boolean;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
@@ -56,6 +62,7 @@ function ConfigPane({
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
isAIQuery,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
stepInterval,
|
||||
@@ -124,6 +131,7 @@ function ConfigPane({
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
isAIQuery={isAIQuery}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
@@ -133,7 +141,7 @@ function ConfigPane({
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfigActions panel={panel} panelId={panelId} />
|
||||
<ConfigActions panel={panel} panelId={panelId} isAIQuery={isAIQuery} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ interface PanelTypeSwitcherProps {
|
||||
queryType: EQueryType;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Whether the panel holds an AI query — disables kinds that can't carry one (List). */
|
||||
isAIQuery?: boolean;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
@@ -28,9 +30,10 @@ function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
isAIQuery,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
const items = usePanelTypeSelectItems({ queryType, signal, isAIQuery });
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { supportsAIQuery } from '../../../../Panels/capabilities';
|
||||
import { getPanelTypeDisabledReason } from '../utils';
|
||||
|
||||
// Every kind currently declares `supportsAIQuery: true`, so the AI gate is exercised
|
||||
// through the capability rather than a stand-in kind that happens to opt out.
|
||||
jest.mock('../../../../Panels/capabilities', () => ({
|
||||
...jest.requireActual('../../../../Panels/capabilities'),
|
||||
supportsAIQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSupportsAIQuery = supportsAIQuery as jest.Mock;
|
||||
|
||||
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
|
||||
const { logs, metrics } = TelemetrytypesSignalDTO;
|
||||
const { logs, metrics, traces } = TelemetrytypesSignalDTO;
|
||||
|
||||
describe('getPanelTypeDisabledReason', () => {
|
||||
it('returns undefined for a supported combination', () => {
|
||||
@@ -70,4 +80,59 @@ describe('getPanelTypeDisabledReason', () => {
|
||||
}),
|
||||
).toBe("List isn't available for PromQL queries");
|
||||
});
|
||||
|
||||
describe('AI queries', () => {
|
||||
it('explains a kind that cannot carry an AI query', () => {
|
||||
mockSupportsAIQuery.mockReturnValue(false);
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: traces,
|
||||
label: 'List',
|
||||
isAIQuery: true,
|
||||
}),
|
||||
).toBe("List isn't available for AI queries");
|
||||
});
|
||||
|
||||
it('allows a kind that supports AI queries', () => {
|
||||
mockSupportsAIQuery.mockReturnValue(true);
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: traces,
|
||||
label: 'Time Series',
|
||||
isAIQuery: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('takes precedence over the query-type and signal reasons', () => {
|
||||
// List is otherwise valid for builder+traces, so only the AI gate can disable it.
|
||||
mockSupportsAIQuery.mockReturnValue(false);
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: PROM,
|
||||
signal: metrics,
|
||||
label: 'List',
|
||||
isAIQuery: true,
|
||||
}),
|
||||
).toBe("List isn't available for AI queries");
|
||||
});
|
||||
|
||||
it('leaves the existing reasons untouched when not an AI query', () => {
|
||||
mockSupportsAIQuery.mockReturnValue(false);
|
||||
expect(
|
||||
getPanelTypeDisabledReason({
|
||||
kind: 'signoz/ListPanel',
|
||||
queryType: QUERY_BUILDER,
|
||||
signal: traces,
|
||||
label: 'List',
|
||||
isAIQuery: false,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
queryType: EQueryType;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Whether the panel holds an AI query — kinds that can't carry one are disabled. */
|
||||
isAIQuery?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,6 +26,7 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
isAIQuery = false,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -34,6 +37,7 @@ export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
isAIQuery,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
@@ -43,6 +47,6 @@ export function usePanelTypeSelectItems({
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
[queryType, signal, isAIQuery],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
supportsAIQuery,
|
||||
} from '../../../Panels/capabilities';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
@@ -25,18 +26,27 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
|
||||
* `undefined` when it can. Drives both the type switcher's disabled state and its
|
||||
* tooltip, so the two never disagree. The query-type reason takes precedence (it's the
|
||||
* outer choice): query types carry no signal, so the signal only matters in builder.
|
||||
*
|
||||
* AI is checked first of all: an AI query is a builder query on traces, so it clears
|
||||
* both of the other gates on kinds that can't carry one (see `supportsAIQuery`).
|
||||
*/
|
||||
export function getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
isAIQuery = false,
|
||||
}: {
|
||||
kind: PanelKind;
|
||||
queryType: EQueryType;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
label: string;
|
||||
/** Whether the panel currently holds an AI query. */
|
||||
isAIQuery?: boolean;
|
||||
}): string | undefined {
|
||||
if (isAIQuery && !supportsAIQuery(kind)) {
|
||||
return `${label} isn't available for AI queries`;
|
||||
}
|
||||
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
|
||||
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ function renderConfigPane(
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
isAIQuery: false,
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
panel: { kind: 'Panel', spec: spec() } as DashboardtypesPanelDTO,
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface SectionEditorContext {
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
yAxisUnit?: string;
|
||||
queryType?: EQueryType;
|
||||
/** Whether the panel holds an AI query — gates the panel-type switcher. */
|
||||
isAIQuery?: boolean;
|
||||
stepInterval?: number;
|
||||
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
|
||||
metricUnit?: string;
|
||||
|
||||
@@ -16,7 +16,7 @@ import styles from './VisualizationSection.module.scss';
|
||||
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
Pick<
|
||||
SectionEditorContext,
|
||||
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
|
||||
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType' | 'isAIQuery'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -33,6 +33,7 @@ function VisualizationSection({
|
||||
onChangePanelKind,
|
||||
queryType,
|
||||
signal,
|
||||
isAIQuery,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
@@ -43,6 +44,7 @@ function VisualizationSection({
|
||||
// supplied in practice; default to Query Builder at this boundary.
|
||||
queryType={queryType ?? EQueryType.QUERY_BUILDER}
|
||||
signal={signal}
|
||||
isAIQuery={isAIQuery}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Atom, Sparkles, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -19,16 +19,27 @@ import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryB
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
supportsAIQuery,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import {
|
||||
AI_QUERY_TAB,
|
||||
isAIQuery,
|
||||
type QueryTabKey,
|
||||
resolveActiveQueryTab,
|
||||
toAIQuery,
|
||||
withAIQueryType,
|
||||
} from './utils';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
@@ -71,12 +82,22 @@ function PanelEditorQueryBuilder({
|
||||
const isListViewPanel = panelKind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
|
||||
|
||||
// The AI tab is not a query type — it stamps `builderQueryType` onto the builder
|
||||
// queries (and pins them to traces, the only signal AI queries support).
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
(nextTab: string): void => {
|
||||
if (nextTab === AI_QUERY_TAB) {
|
||||
redirectWithQueryBuilderData({
|
||||
...toAIQuery(currentQuery),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
});
|
||||
return;
|
||||
}
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
...withAIQueryType(currentQuery, false),
|
||||
queryType: nextTab as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
@@ -103,10 +124,43 @@ function PanelEditorQueryBuilder({
|
||||
[panelKind, signal],
|
||||
);
|
||||
|
||||
// Derived to a boolean before the memo below: `currentQuery` gets a fresh identity on
|
||||
// every query edit, so depending on it there would rebuild every tab's element tree
|
||||
// (QueryBuilderV2 included) on each keystroke.
|
||||
const hasAIQuery = isAIQuery(currentQuery);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
const supportedQueryTypes: QueryTabKey[] = getSupportedQueryTypes(panelKind);
|
||||
// The flag gates authoring, not reading: a panel that already holds an AI query
|
||||
// keeps its tab when the flag is off, so an existing panel stays editable (and
|
||||
// `activeKey` never points at a tab that isn't rendered) instead of opening blank.
|
||||
const showAITab =
|
||||
supportsAIQuery(panelKind) && (isAIObservabilityEnabled || hasAIQuery);
|
||||
const supportedTabs = showAITab
|
||||
? [...supportedQueryTypes, AI_QUERY_TAB]
|
||||
: supportedQueryTypes;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[AI_QUERY_TAB]: {
|
||||
icon: <Sparkles size={14} />,
|
||||
label: 'AI Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
config={{
|
||||
initialDataSource: DataSource.TRACES,
|
||||
queryVariant: 'static',
|
||||
}}
|
||||
version="v3"
|
||||
isListViewPanel={isListViewPanel}
|
||||
queryComponents={{}}
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
@@ -141,17 +195,25 @@ function PanelEditorQueryBuilder({
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
key: queryType,
|
||||
return supportedTabs.map((tabKey) => ({
|
||||
key: tabKey,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
{queryTypeComponents[tabKey].icon}
|
||||
<Typography>{queryTypeComponents[tabKey].label}</Typography>
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
children: queryTypeComponents[tabKey].component,
|
||||
}));
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
}, [
|
||||
panelKind,
|
||||
panelType,
|
||||
filterConfigs,
|
||||
isDarkMode,
|
||||
isListViewPanel,
|
||||
isAIObservabilityEnabled,
|
||||
hasAIQuery,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -166,7 +228,7 @@ function PanelEditorQueryBuilder({
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
activeKey={resolveActiveQueryTab(currentQuery)}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
|
||||
@@ -13,6 +14,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
|
||||
let mockAIObservabilityEnabled = true;
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => mockAIObservabilityEnabled,
|
||||
}));
|
||||
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
|
||||
QueryBuilderV2: (props: unknown): null => {
|
||||
mockQueryBuilderV2(props);
|
||||
@@ -62,6 +67,7 @@ function lastQueryBuilderProps(): {
|
||||
isListViewPanel: boolean;
|
||||
showTraceOperator: boolean;
|
||||
filterConfigs: unknown;
|
||||
config?: unknown;
|
||||
} {
|
||||
const calls = mockQueryBuilderV2.mock.calls;
|
||||
return calls[calls.length - 1][0];
|
||||
@@ -70,20 +76,54 @@ function lastQueryBuilderProps(): {
|
||||
describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities guard)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAIObservabilityEnabled = true;
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the Query Builder tab for the List kind', () => {
|
||||
it('shows the builder tabs but no raw-query tabs for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the AI tab when the feature flag is off', () => {
|
||||
mockAIObservabilityEnabled = false;
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('AI Query Builder')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The flag gates authoring, not reading: an existing AI panel stays editable, and
|
||||
// its derived active tab always has a tab to point at.
|
||||
it('keeps the AI tab with the flag off when the query already is an AI query', () => {
|
||||
mockAIObservabilityEnabled = false;
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Query Builder + ClickHouse but not PromQL for the Table kind', () => {
|
||||
renderBuilder('signoz/TablePanel');
|
||||
|
||||
@@ -92,21 +132,62 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all three tabs for the Time Series kind', () => {
|
||||
it('shows all four tabs for the Time Series kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The AI tab is derived from `builderQueryType`, not from a stored tab key.
|
||||
it('activates the AI tab when the builder query carries the AI envelope tag', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pins the AI tab builder to traces so the signal cannot be changed', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(lastQueryBuilderProps().config).toStrictEqual({
|
||||
initialDataSource: DataSource.TRACES,
|
||||
queryVariant: 'static',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
AI_QUERY_TAB,
|
||||
isAIQuery,
|
||||
resolveActiveQueryTab,
|
||||
toAIQuery,
|
||||
withAIQueryType,
|
||||
} from '../utils';
|
||||
|
||||
function makeQuery(
|
||||
queryData: Record<string, unknown>[],
|
||||
queryType: EQueryType = EQueryType.QUERY_BUILDER,
|
||||
): Query {
|
||||
return {
|
||||
queryType,
|
||||
builder: { queryData, queryFormulas: [], queryTraceOperator: [] },
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'test',
|
||||
} as unknown as Query;
|
||||
}
|
||||
|
||||
describe('isAIQuery', () => {
|
||||
it('is true when any builder query carries the AI envelope tag', () => {
|
||||
expect(
|
||||
isAIQuery(
|
||||
makeQuery([{ queryName: 'A' }, { builderQueryType: 'builder_ai_query' }]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for plain builder queries and for an empty builder', () => {
|
||||
expect(isAIQuery(makeQuery([{ queryName: 'A' }]))).toBe(false);
|
||||
expect(isAIQuery(makeQuery([]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActiveQueryTab', () => {
|
||||
it('selects the AI tab for a tagged builder query', () => {
|
||||
expect(
|
||||
resolveActiveQueryTab(makeQuery([{ builderQueryType: 'builder_ai_query' }])),
|
||||
).toBe(AI_QUERY_TAB);
|
||||
});
|
||||
|
||||
it('selects the query type for an untagged query', () => {
|
||||
expect(resolveActiveQueryTab(makeQuery([{ queryName: 'A' }]))).toBe(
|
||||
EQueryType.QUERY_BUILDER,
|
||||
);
|
||||
});
|
||||
|
||||
// A PromQL panel reads its queries from a different bucket, so a stale tag on the
|
||||
// builder bucket must not steal the active tab.
|
||||
it('keeps PromQL selected even if the builder bucket carries a tag', () => {
|
||||
expect(
|
||||
resolveActiveQueryTab(
|
||||
makeQuery([{ builderQueryType: 'builder_ai_query' }], EQueryType.PROM),
|
||||
),
|
||||
).toBe(EQueryType.PROM);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAIQuery', () => {
|
||||
// The backend decodes a builder_ai_query spec as QueryBuilderQuery[TraceAggregation],
|
||||
// which has no `metricName` — a carried-over metrics aggregation fails the request.
|
||||
it('re-seeds a metrics query onto traces, dropping the metric aggregation', () => {
|
||||
const result = toAIQuery(
|
||||
makeQuery([
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregations: [{ metricName: 'signoz_latency_bucket' }],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const [queryData] = result.builder.queryData;
|
||||
expect(queryData.dataSource).toBe(DataSource.TRACES);
|
||||
expect(queryData.aggregations).toStrictEqual([{ expression: 'count() ' }]);
|
||||
expect(queryData.builderQueryType).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('keeps the filter on a query already using traces', () => {
|
||||
const result = toAIQuery(
|
||||
makeQuery([
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.TRACES,
|
||||
filter: { expression: "service.name = 'checkout'" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].filter).toStrictEqual({
|
||||
expression: "service.name = 'checkout'",
|
||||
});
|
||||
expect(result.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('preserves the query name when re-seeding', () => {
|
||||
const result = toAIQuery(
|
||||
makeQuery([{ queryName: 'B', dataSource: DataSource.LOGS }]),
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].queryName).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAIQueryType', () => {
|
||||
it('stamps the tag onto every builder query', () => {
|
||||
const result = withAIQueryType(
|
||||
makeQuery([{ queryName: 'A' }, { queryName: 'B' }]),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.builder.queryData.map((item) => item.builderQueryType),
|
||||
).toStrictEqual(['builder_ai_query', 'builder_ai_query']);
|
||||
});
|
||||
|
||||
it('deletes the key when clearing, rather than setting undefined', () => {
|
||||
const result = withAIQueryType(
|
||||
makeQuery([{ queryName: 'A', builderQueryType: 'builder_ai_query' }]),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0]).not.toHaveProperty('builderQueryType');
|
||||
expect(result.builder.queryData[0]).toStrictEqual({ queryName: 'A' });
|
||||
});
|
||||
|
||||
it('returns the query untouched when it already matches', () => {
|
||||
const tagged = makeQuery([{ builderQueryType: 'builder_ai_query' }]);
|
||||
const plain = makeQuery([{ queryName: 'A' }]);
|
||||
|
||||
expect(withAIQueryType(tagged, true)).toBe(tagged);
|
||||
expect(withAIQueryType(plain, false)).toBe(plain);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
/**
|
||||
* Tab key for the AI query builder. Deliberately not an `EQueryType`: an AI query is
|
||||
* a builder query carrying `builderQueryType: 'builder_ai_query'`, so the query type
|
||||
* on the wire stays `builder` and only the per-query envelope tag differs. Keeping the
|
||||
* tab out of the enum leaves that tag the single source of truth.
|
||||
*/
|
||||
export const AI_QUERY_TAB = 'ai_builder' as const;
|
||||
|
||||
export type QueryTabKey = EQueryType | typeof AI_QUERY_TAB;
|
||||
|
||||
export function isAIQuery(query: Query): boolean {
|
||||
return query.builder.queryData.some(
|
||||
(item) => item.builderQueryType === 'builder_ai_query',
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab to highlight — derived from the queries, never stored separately. */
|
||||
export function resolveActiveQueryTab(query: Query): QueryTabKey {
|
||||
return query.queryType === EQueryType.QUERY_BUILDER && isAIQuery(query)
|
||||
? AI_QUERY_TAB
|
||||
: query.queryType;
|
||||
}
|
||||
|
||||
/** Carried across a signal switch, mirroring the builder's own datasource selector. */
|
||||
const PRESERVED_ON_SIGNAL_SWITCH = ['queryName', 'expression'];
|
||||
|
||||
/**
|
||||
* Re-seed a non-traces query with the traces defaults, the way `handleChangeDataSource`
|
||||
* does. AI queries are traces-only, and a leftover metrics aggregation carries
|
||||
* `metricName` — a field the backend rejects on a trace spec. A query already on traces
|
||||
* keeps its filters, so switching tabs on a trace query is non-destructive.
|
||||
*/
|
||||
function toTracesQueryData(item: IBuilderQuery): IBuilderQuery {
|
||||
if (item.dataSource === DataSource.TRACES) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const tracesDefaults = Object.fromEntries(
|
||||
Object.entries(initialQueryBuilderFormValuesMap[DataSource.TRACES]).filter(
|
||||
([key]) => !PRESERVED_ON_SIGNAL_SWITCH.includes(key),
|
||||
),
|
||||
);
|
||||
return { ...item, ...tracesDefaults, dataSource: DataSource.TRACES };
|
||||
}
|
||||
|
||||
/** Move a query onto the AI builder: pin every query to traces and tag it. */
|
||||
export function toAIQuery(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item) => ({
|
||||
...toTracesQueryData(item),
|
||||
builderQueryType: 'builder_ai_query' as const,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp or clear `builderQueryType` across every builder query. Returns the query
|
||||
* untouched when it already matches, and deletes the key rather than setting it to
|
||||
* `undefined` — the dirty checks compare by value, so a stray key reads as an edit.
|
||||
*/
|
||||
export function withAIQueryType(query: Query, enabled: boolean): Query {
|
||||
const needsUpdate = query.builder.queryData.some(
|
||||
(item) => (item.builderQueryType === 'builder_ai_query') !== enabled,
|
||||
);
|
||||
if (!needsUpdate) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item): IBuilderQuery => {
|
||||
const { builderQueryType: _dropped, ...rest } = item;
|
||||
return enabled ? { ...rest, builderQueryType: 'builder_ai_query' } : rest;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,10 @@ const mockOnChangePanelKind = jest.fn();
|
||||
const mockSave = jest.fn().mockResolvedValue('panel-1');
|
||||
|
||||
const mockUseDraft = jest.fn();
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('../hooks/usePanelEditorDraft', () => ({
|
||||
usePanelEditorDraft: (panel: unknown): unknown => mockUseDraft(panel),
|
||||
}));
|
||||
@@ -70,7 +74,9 @@ jest.mock('../hooks/useSeedMetricUnit', () => ({
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => ({ currentQuery: { queryType: 'builder' } }),
|
||||
useQueryBuilder: (): unknown => ({
|
||||
currentQuery: { queryType: 'builder', builder: { queryData: [] } },
|
||||
}),
|
||||
}));
|
||||
jest.mock(
|
||||
'../../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions',
|
||||
|
||||
@@ -21,6 +21,10 @@ import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const { useHistory: useRouterHistory } =
|
||||
jest.requireActual('react-router-dom');
|
||||
|
||||
@@ -5,7 +5,10 @@ import { handleQueryChange } from 'lib/query/panelQuery';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../../Panels/capabilities';
|
||||
import {
|
||||
resolveQueryType,
|
||||
supportsAIQuery,
|
||||
} from '../../../Panels/capabilities';
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
@@ -19,6 +22,7 @@ jest.mock('lib/query/panelQuery', () => ({
|
||||
}));
|
||||
jest.mock('../../../Panels/capabilities', () => ({
|
||||
resolveQueryType: jest.fn(),
|
||||
supportsAIQuery: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
@@ -33,6 +37,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
|
||||
const mockSupportsAIQuery = supportsAIQuery as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
@@ -96,7 +101,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
const state = builderState({
|
||||
id: 'q',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -114,7 +123,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = {
|
||||
id: 'table-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
@@ -142,7 +155,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState({
|
||||
id: 'ts-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -169,7 +186,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
const promQuery = {
|
||||
id: 'prom',
|
||||
queryType: 'promql',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -191,10 +212,88 @@ describe('usePanelTypeSwitch', () => {
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
// `handleQueryChange` rebuilds from a field allow-list that omits `builderQueryType`,
|
||||
// so the tag has to be re-applied after the rebuild or the AI tab silently reverts.
|
||||
it('re-applies the AI envelope tag when the new kind supports AI queries', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockSupportsAIQuery.mockReturnValue(true);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ orderBy: [] }] },
|
||||
} as unknown as Query);
|
||||
const aiQuery = {
|
||||
id: 'ai-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
} as unknown as Query;
|
||||
const state = builderState(aiQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the AI envelope tag when the new kind has no AI tab', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockSupportsAIQuery.mockReturnValue(false);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ orderBy: [] }] },
|
||||
} as unknown as Query);
|
||||
const aiQuery = {
|
||||
id: 'ai-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
} as unknown as Query;
|
||||
const state = builderState(aiQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// The rebuild receives an untagged query…
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).builder.queryData[0]).not.toHaveProperty(
|
||||
'builderQueryType',
|
||||
);
|
||||
// …and nothing re-applies it afterwards.
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = {
|
||||
id: 'table-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const listQuery = {
|
||||
id: 'list-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import { resolveQueryType, supportsAIQuery } from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
import { isAIQuery, withAIQueryType } from '../PanelEditorQueryBuilder/utils';
|
||||
|
||||
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
|
||||
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
@@ -139,16 +140,24 @@ export function usePanelTypeSwitch({
|
||||
// First visit → coerce the query type if the new kind disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const queryType = resolveQueryType(newKind, query.queryType);
|
||||
// AI-ness rides on the query, not on `queryType`, so `resolveQueryType` can't
|
||||
// see it: carry it across only when the new kind has an AI tab to surface it.
|
||||
const keepAIQueryType = supportsAIQuery(newKind) && isAIQuery(query);
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
{ ...withAIQueryType(query, false), queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const nextQuery =
|
||||
const reordered =
|
||||
newKind === 'signoz/ListPanel'
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
// `handleQueryChange` rebuilds each query from an allow-list of fields that
|
||||
// doesn't include `builderQueryType`, so re-stamp it after the rebuild.
|
||||
const nextQuery = keepAIQueryType
|
||||
? withAIQueryType(reordered, true)
|
||||
: reordered;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import layoutStorage from './layoutStorage';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
|
||||
import { isAIQuery } from './PanelEditorQueryBuilder/utils';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
@@ -353,6 +354,7 @@ function PanelEditorContainer({
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
isAIQuery={isAIQuery(currentQuery)}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
|
||||
@@ -39,6 +39,15 @@ export function isQueryTypeSupportedByPanelKind(
|
||||
return getSupportedQueryTypes(kind).includes(queryType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a kind offers the AI query builder. Separate from `supportedQueryTypes`
|
||||
* because an AI query is a builder query carrying `builderQueryType`, not its own
|
||||
* `EQueryType` — the tab is UI state, the wire type stays `builder`.
|
||||
*/
|
||||
export function supportsAIQuery(kind: PanelKind): boolean {
|
||||
return getPanelDefinition(kind).supportsAIQuery === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master guard: is this panel kind renderable with this query type (and, in builder
|
||||
* mode, this signal)? ClickHouse/PromQL queries carry no signal, so the signal is
|
||||
|
||||
@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
// Bars are binned client-side from a raw time series, so the request asks for a
|
||||
// step interval wide enough to keep the bar count readable (V1 parity).
|
||||
|
||||
@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
// Buckets are computed client-side from the raw series, so the request is a plain
|
||||
// time series — the bucket count is a display concern, not a query one.
|
||||
|
||||
@@ -22,6 +22,9 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
|
||||
// list configs — the capabilities guard is the single source for both.
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
|
||||
// List is the one kind the backend accepts only as a bare query plugin, so its AI
|
||||
// queries carry the envelope tag in the plugin kind (`signoz/AIBuilderQuery`).
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {
|
||||
default: {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
|
||||
@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
|
||||
|
||||
@@ -18,6 +18,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
// The only kind that asks the server to transpose its scalar result into UI rows.
|
||||
queryCapabilities: {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
queryCapabilities: {
|
||||
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
supportedQueryTypes: EQueryType[];
|
||||
/** Kind offers the AI query builder — a traces-only builder variant, not its own query language. */
|
||||
supportsAIQuery?: boolean;
|
||||
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
|
||||
queryBuilderFields: QueryBuilderFieldRule;
|
||||
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
|
||||
|
||||
@@ -4,9 +4,11 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
import { isBuilderEnvelope } from '../../queryV5/builderEnvelope';
|
||||
|
||||
/**
|
||||
* Flattens a panel's queries into its builder queries, unwrapping
|
||||
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
|
||||
* Flattens a panel's queries into its builder queries (`builder_query` and its AI
|
||||
* variant), unwrapping `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
|
||||
* TraceOperator) are dropped — they lack the legend/groupBy/aggregation context
|
||||
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
|
||||
*/
|
||||
@@ -16,13 +18,16 @@ export function getBuilderQueries(
|
||||
const flattened: BuilderQuery[] = [];
|
||||
queries.forEach((envelope) => {
|
||||
const plugin = envelope.spec.plugin;
|
||||
if (plugin.kind === 'signoz/BuilderQuery') {
|
||||
if (
|
||||
plugin.kind === 'signoz/BuilderQuery' ||
|
||||
plugin.kind === 'signoz/AIBuilderQuery'
|
||||
) {
|
||||
flattened.push(plugin.spec as BuilderQuery);
|
||||
return;
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
(plugin.spec.queries || []).forEach((sub) => {
|
||||
if (sub.type === 'builder_query') {
|
||||
if (isBuilderEnvelope(sub)) {
|
||||
flattened.push(sub.spec as BuilderQuery);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { isAIBuilderEnvelope } from '../../queryV5/builderEnvelope';
|
||||
import { toQueryEnvelopes } from '../../queryV5/buildQueryRangeRequest';
|
||||
|
||||
/**
|
||||
* Whether a saved panel holds an AI query. Read from the panel spec rather than the
|
||||
* query-builder provider, for the dashboard-grid actions that run outside the editor.
|
||||
*/
|
||||
export function panelHasAIQuery(panel: DashboardtypesPanelDTO): boolean {
|
||||
return toQueryEnvelopes(panel.spec.queries).some(isAIBuilderEnvelope);
|
||||
}
|
||||
@@ -410,4 +410,37 @@ describe('usePanelActionItems', () => {
|
||||
(createAlert as { onClick: () => void }).onClick();
|
||||
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
|
||||
});
|
||||
|
||||
// The alert builder has no AI tab, so seeding it would hand back a plain trace query.
|
||||
it('disables create-alert on a panel holding an AI query', () => {
|
||||
const aiPanel = {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'AI' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [
|
||||
{
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelActionItems({ ...baseArgs, panel: aiPanel }),
|
||||
);
|
||||
const createAlert = result.current.items.find(
|
||||
(i) => 'key' in i && i.key === 'create-alert',
|
||||
);
|
||||
expect((createAlert as { disabled?: boolean }).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useConfirmableAction,
|
||||
} from 'hooks/useConfirmableAction';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { panelHasAIQuery } from 'pages/DashboardPage/DashboardContainer/Panels/utils/panelHasAIQuery';
|
||||
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
|
||||
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
|
||||
@@ -33,6 +34,9 @@ import { PANEL_ACTION_META } from './panelActionMeta';
|
||||
import DisabledMenuItemLabel from '../../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../../hooks/useDashboardEditGuard';
|
||||
|
||||
const ALERT_FROM_AI_PANEL_REASON =
|
||||
'Alerts can\u2019t be created from AI queries yet';
|
||||
|
||||
// Stable fallback so renders without layout context don't churn the mutation
|
||||
// hooks' deps (a fresh [] each render would re-create their callbacks).
|
||||
const EMPTY_SECTIONS: DashboardSection[] = [];
|
||||
@@ -89,6 +93,7 @@ export function usePanelActionItems({
|
||||
const clonePanel = useClonePanel({ sections });
|
||||
|
||||
const panelCapabilities = getPanelDefinition(panelKind).actions;
|
||||
const isAIPanel = panelHasAIQuery(panel);
|
||||
const downloadItem = useDownloadPanelMenuItem({
|
||||
panelId,
|
||||
panel,
|
||||
@@ -166,12 +171,21 @@ export function usePanelActionItems({
|
||||
}
|
||||
|
||||
// Create Alerts opens a new tab and never mutates the dashboard, so —
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity).
|
||||
// unlike edit/clone — it isn't gated on editability (V1 parity). AI queries are
|
||||
// the exception: the alert builder has no AI tab, so seeding it from one would
|
||||
// silently hand back a plain trace query.
|
||||
if (panelCapabilities.createAlert) {
|
||||
dataGroup.push({
|
||||
key: 'create-alert',
|
||||
label: 'Create Alerts',
|
||||
label: isAIPanel ? (
|
||||
<DisabledMenuItemLabel reason={ALERT_FROM_AI_PANEL_REASON}>
|
||||
Create Alerts
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
'Create Alerts'
|
||||
),
|
||||
icon: <Bell size={14} />,
|
||||
disabled: isAIPanel,
|
||||
onClick: (): void => createAlert(panel, panelId),
|
||||
});
|
||||
}
|
||||
@@ -227,6 +241,7 @@ export function usePanelActionItems({
|
||||
sections,
|
||||
panelId,
|
||||
downloadItem,
|
||||
isAIPanel,
|
||||
openView,
|
||||
openPanelEditor,
|
||||
createAlert,
|
||||
|
||||
@@ -54,6 +54,7 @@ function ViewPanelModalContent({
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType,
|
||||
isAIQuery,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
@@ -147,6 +148,7 @@ function ViewPanelModalContent({
|
||||
onSwitchToEdit={onSwitchToEdit}
|
||||
panelKind={draft.spec.plugin.kind}
|
||||
queryType={queryType}
|
||||
isAIQuery={isAIQuery}
|
||||
signal={signal}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
onResetQuery={resetQuery}
|
||||
|
||||
@@ -38,6 +38,8 @@ interface ViewPanelModalHeaderProps {
|
||||
queryType: EQueryType;
|
||||
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Whether the panel holds an AI query — disables kinds that can't carry one. */
|
||||
isAIQuery: boolean;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
/** Restore the saved query + kind (drilldown reset). */
|
||||
onResetQuery: () => void;
|
||||
@@ -59,12 +61,17 @@ function ViewPanelModalHeader({
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
isAIQuery,
|
||||
onChangePanelKind,
|
||||
onResetQuery,
|
||||
}: ViewPanelModalHeaderProps): JSX.Element {
|
||||
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
|
||||
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
|
||||
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
|
||||
const panelTypeItems = usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
isAIQuery,
|
||||
});
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
|
||||
import { isAIQuery } from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/utils';
|
||||
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import {
|
||||
type PanelQueryTimeOverride,
|
||||
@@ -50,6 +51,8 @@ export interface UseViewPanelModeReturn {
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Active query type (selected builder tab) — drives the panel-type selector's disabled rule. */
|
||||
queryType: EQueryType;
|
||||
/** Whether the panel holds an AI query — gates the header's kind selector. */
|
||||
isAIQuery: boolean;
|
||||
/** Query result for the draft over the per-view window. */
|
||||
query: UsePanelQueryResult;
|
||||
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
|
||||
@@ -176,6 +179,7 @@ export function useViewPanelMode({
|
||||
panelDefinition,
|
||||
signal,
|
||||
queryType: currentQuery.queryType,
|
||||
isAIQuery: isAIQuery(currentQuery),
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
|
||||
@@ -46,6 +46,10 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
|
||||
@@ -16,6 +16,10 @@ import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
|
||||
import { useViewPanel } from '../hooks/useViewPanel';
|
||||
|
||||
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
|
||||
jest.mock('hooks/useIsAIObservabilityEnabled', () => ({
|
||||
useIsAIObservabilityEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
|
||||
@@ -25,6 +25,17 @@ function bareBuilderQuery(
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
function bareAIBuilderQuery(
|
||||
spec: Record<string, unknown>,
|
||||
): DashboardtypesQueryDTO[] {
|
||||
return [
|
||||
{
|
||||
kind: 'raw',
|
||||
spec: { plugin: { kind: 'signoz/AIBuilderQuery', spec } },
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
function compositeQuery(
|
||||
envelopes: Record<string, unknown>[],
|
||||
): DashboardtypesQueryDTO[] {
|
||||
@@ -94,6 +105,15 @@ describe('toQueryEnvelopes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('wraps a bare AIBuilderQuery into a single builder_ai_query envelope', () => {
|
||||
const envelopes = toQueryEnvelopes(
|
||||
bareAIBuilderQuery({ name: 'A', signal: 'traces' }),
|
||||
);
|
||||
expect(envelopes).toStrictEqual([
|
||||
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes a CompositeQuery envelope list through verbatim', () => {
|
||||
const subqueries = [
|
||||
{ type: 'builder_query', spec: { name: 'A' } },
|
||||
@@ -304,6 +324,23 @@ describe('buildQueryRangeRequest', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('injects the BAR stepInterval into builder_ai_query envelopes too', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: compositeQuery([
|
||||
{ type: 'builder_ai_query', spec: { name: 'A', signal: 'traces' } },
|
||||
]),
|
||||
queryCapabilities: BAR_CAPABILITIES,
|
||||
startMs: START_MS,
|
||||
endMs: START_MS + HOUR_MS,
|
||||
});
|
||||
const envelope = request.compositeQuery?.queries?.[0];
|
||||
const spec = (envelope?.spec ?? {}) as { stepInterval?: number };
|
||||
expect(spec.stepInterval).toBe(
|
||||
getBarStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
|
||||
);
|
||||
expect(envelope?.type).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('preserves a user-set stepInterval on BAR builder queries', () => {
|
||||
const request = buildQueryRangeRequest({
|
||||
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
|
||||
|
||||
@@ -2,7 +2,11 @@ import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryAIWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -193,6 +197,57 @@ describe('persesQueryAdapters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an AI builder query through toPerses → fromPerses', () => {
|
||||
const original: Query = initialQueryAIWithType;
|
||||
|
||||
const perses = toPerses(original, PANEL_TYPES.TIME_SERIES);
|
||||
const { queries } = perses[0].spec.plugin.spec as {
|
||||
queries: Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
};
|
||||
expect(queries[0].type).toBe('builder_ai_query');
|
||||
|
||||
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(restored.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(restored.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps builder_ai_query on a second save with no edits', () => {
|
||||
// The silent-downgrade catcher: an untouched AI panel that reloads and saves
|
||||
// again must not come back as a plain trace query.
|
||||
const saved = toPerses(initialQueryAIWithType, PANEL_TYPES.TIME_SERIES);
|
||||
const resaved = toPerses(
|
||||
fromPerses(saved, PANEL_TYPES.TIME_SERIES),
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
const { queries } = resaved[0].spec.plugin.spec as {
|
||||
queries: Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
};
|
||||
expect(queries[0].type).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('emits a bare signoz/AIBuilderQuery for a List panel holding an AI query', () => {
|
||||
const result = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].kind).toBe('raw');
|
||||
expect(result[0].spec.plugin.kind).toBe('signoz/AIBuilderQuery');
|
||||
});
|
||||
|
||||
it('preserves a List AI query through toPerses → fromPerses', () => {
|
||||
// The bare plugin has no envelope tag, so the kind is the only thing carrying
|
||||
// it — a downgrade to signoz/BuilderQuery would reload as a plain trace query.
|
||||
const perses = toPerses(initialQueryAIWithType, PANEL_TYPES.LIST);
|
||||
const restored = fromPerses(perses, PANEL_TYPES.LIST);
|
||||
|
||||
expect(restored.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a List builder query through toPerses → fromPerses', () => {
|
||||
const original: Query = initialQueriesMap[DataSource.LOGS];
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ import type {
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5PromQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5QueryRangeRequestDTOVariables,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
@@ -18,6 +20,8 @@ import {
|
||||
|
||||
import type { PanelQueryCapabilities } from '../Panels/types/panelCapabilities';
|
||||
|
||||
import { isBuilderEnvelope, withBuilderSpec } from './builderEnvelope';
|
||||
|
||||
// Narrow view over the envelope spec variants. Orval erases envelope `spec` to `unknown`, so
|
||||
// shared fields are read through this view with a localized cast at the envelope boundary.
|
||||
interface QuerySpecView {
|
||||
@@ -58,6 +62,19 @@ export function toQueryEnvelopes(
|
||||
spec: plugin.spec as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
},
|
||||
];
|
||||
case 'signoz/AIBuilderQuery':
|
||||
// The bare AI plugin carries its envelope tag in the plugin kind rather than
|
||||
// the spec — the only way an AI query survives a round trip through a panel
|
||||
// that can't wrap it in a CompositeQuery (List). Orval mints the plugin and
|
||||
// envelope spec as separate types with their own single-member `signal` enum,
|
||||
// so the (identical) shapes cross over through `unknown`.
|
||||
return [
|
||||
{
|
||||
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
|
||||
spec:
|
||||
plugin.spec as unknown as Querybuildertypesv5QueryEnvelopeBuilderAIDTO['spec'],
|
||||
},
|
||||
];
|
||||
case 'signoz/PromQLQuery':
|
||||
return [
|
||||
{
|
||||
@@ -125,22 +142,13 @@ function withBarStepInterval(
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return envelope;
|
||||
}
|
||||
if (envelope.spec?.stepInterval) {
|
||||
return envelope;
|
||||
}
|
||||
return {
|
||||
...envelope,
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
stepInterval,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
};
|
||||
return withBuilderSpec(envelope, { stepInterval });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -153,10 +161,7 @@ function withListOrderTiebreaker(
|
||||
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return envelope;
|
||||
}
|
||||
const spec = envelope.spec as QuerySpecView;
|
||||
@@ -173,16 +178,12 @@ function withListOrderTiebreaker(
|
||||
direction: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
},
|
||||
];
|
||||
return {
|
||||
...envelope,
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
order: [
|
||||
...primary,
|
||||
{ key: { name: 'id' }, direction: primary[0].direction },
|
||||
],
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
};
|
||||
return withBuilderSpec(envelope, {
|
||||
order: [
|
||||
...primary,
|
||||
{ key: { name: 'id' }, direction: primary[0].direction },
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,20 +196,10 @@ function withPagination(
|
||||
{ offset, limit }: { offset: number; limit: number },
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] {
|
||||
return envelopes.map((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return envelope;
|
||||
}
|
||||
return {
|
||||
...envelope,
|
||||
spec: {
|
||||
...envelope.spec,
|
||||
offset,
|
||||
limit,
|
||||
} as Querybuildertypesv5BuilderQuerySpecDTO,
|
||||
};
|
||||
return withBuilderSpec(envelope, { offset, limit });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,11 +292,7 @@ export function hasRunnableQueries(queries: DashboardtypesQueryDTO[]): boolean {
|
||||
}
|
||||
|
||||
const metricsSpecs = envelopes
|
||||
.filter(
|
||||
(envelope) =>
|
||||
envelope.type ===
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
)
|
||||
.filter(isBuilderEnvelope)
|
||||
.map((envelope) => envelope.spec as QuerySpecView)
|
||||
.filter((spec) => spec.signal === 'metrics');
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
Querybuildertypesv5OrderByDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTO,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
Querybuildertypesv5StepDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* A builder query on the wire, in either flavour. `builder_ai_query` is the same
|
||||
* builder spec pinned to traces — it differs from `builder_query` only in the
|
||||
* envelope tag, so every "is this a builder query?" decision must accept both.
|
||||
*/
|
||||
export type BuilderEnvelope =
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderDTO
|
||||
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO;
|
||||
|
||||
export const BUILDER_ENVELOPE_TYPES: readonly Querybuildertypesv5QueryEnvelopeDTO['type'][] =
|
||||
[
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query,
|
||||
];
|
||||
|
||||
/**
|
||||
* The single predicate for "this envelope carries a builder query". Use it instead of
|
||||
* comparing `type` to the `builder_query` literal: a bare literal silently excludes AI
|
||||
* queries, which is how an AI panel ends up with no legends, no signal and an
|
||||
* un-widened bar step interval.
|
||||
*/
|
||||
export function isBuilderEnvelope(
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): envelope is BuilderEnvelope {
|
||||
return BUILDER_ENVELOPE_TYPES.includes(envelope.type);
|
||||
}
|
||||
|
||||
/** Whether an envelope is specifically the AI flavour (traces-only, AI-authored). */
|
||||
export function isAIBuilderEnvelope(
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): envelope is Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
return (
|
||||
envelope.type ===
|
||||
Querybuildertypesv5QueryEnvelopeBuilderAIDTOType.builder_ai_query
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The request-time fields the query-range builder stamps onto a builder spec. Orval
|
||||
* types the AI envelope's `spec` as the traces aggregation alone and the plain one as
|
||||
* the three-signal union, so a spec rebuilt in place fits neither without narrowing —
|
||||
* this is the shared shape of what those rewrites actually write.
|
||||
*/
|
||||
export interface BuilderSpecPatch {
|
||||
stepInterval?: Querybuildertypesv5StepDTO;
|
||||
order?: Querybuildertypesv5OrderByDTO[] | null;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges `patch` into a builder envelope's spec, preserving the envelope's own type —
|
||||
* so a `builder_ai_query` stays one instead of being flattened back to `builder_query`.
|
||||
*/
|
||||
export function withBuilderSpec<T extends BuilderEnvelope>(
|
||||
envelope: T,
|
||||
patch: BuilderSpecPatch,
|
||||
): T {
|
||||
return { ...envelope, spec: { ...envelope.spec, ...patch } } as T;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import type {
|
||||
DashboardtypesAIBuilderQuerySpecDTO,
|
||||
DashboardtypesBuilderQuerySpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
DashboardtypesQueryPluginDTO,
|
||||
Querybuildertypesv5CompositeQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpecDTOKind as AIBuilderQueryPluginKind,
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind as BuilderQueryPluginKind,
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQueryDTOKind as CompositeQueryPluginKind,
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
Querybuildertypesv5QueryEnvelopePromQLDTOType,
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
@@ -21,6 +23,11 @@ import type { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
type BuilderEnvelope,
|
||||
isAIBuilderEnvelope,
|
||||
isBuilderEnvelope,
|
||||
} from './builderEnvelope';
|
||||
import { toQueryEnvelopes } from './buildQueryRangeRequest';
|
||||
|
||||
/**
|
||||
@@ -44,10 +51,27 @@ const toGeneratedEnvelopes = (
|
||||
): Querybuildertypesv5QueryEnvelopeDTO[] =>
|
||||
envelopes as unknown as Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
|
||||
const isBuilderQueryEnvelope = (
|
||||
envelope: Querybuildertypesv5QueryEnvelopeDTO,
|
||||
): boolean =>
|
||||
envelope.type === Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query;
|
||||
/**
|
||||
* The bare plugin a builder envelope serializes to. The AI flavour gets its own kind
|
||||
* because a bare plugin carries no envelope tag — dropped to `signoz/BuilderQuery` it
|
||||
* would reload as a plain trace query. Orval mints the plugin and envelope spec as
|
||||
* separate types with their own single-member `signal` enum, so the (identical) AI
|
||||
* shapes cross over through `unknown`.
|
||||
*/
|
||||
const toBareBuilderPlugin = (
|
||||
envelope: BuilderEnvelope,
|
||||
): DashboardtypesQueryPluginDTO =>
|
||||
isAIBuilderEnvelope(envelope)
|
||||
? {
|
||||
kind: AIBuilderQueryPluginKind['signoz/AIBuilderQuery'],
|
||||
spec: envelope.spec as unknown as DashboardtypesAIBuilderQuerySpecDTO,
|
||||
}
|
||||
: {
|
||||
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
|
||||
// The generated envelope union doesn't discriminate `spec` by `type`, so
|
||||
// narrow the builder query to the dashboard builder spec.
|
||||
spec: envelope.spec as DashboardtypesBuilderQuerySpecDTO,
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the V1 explorer's `pageSize`/`offset` before conversion — the shared mapper folds
|
||||
@@ -170,21 +194,14 @@ export function toPerses(
|
||||
const envelopes = toGeneratedEnvelopes(composite.queries ?? []);
|
||||
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const builder = envelopes.find(isBuilderQueryEnvelope);
|
||||
const builder = envelopes.find(isBuilderEnvelope);
|
||||
if (!builder) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: panelTypeToRequestType(panelType),
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: BuilderQueryPluginKind['signoz/BuilderQuery'],
|
||||
// The generated envelope union doesn't discriminate `spec` by `type`, so
|
||||
// narrow the filtered builder query to the dashboard builder spec.
|
||||
spec: builder.spec as DashboardtypesBuilderQuerySpecDTO,
|
||||
},
|
||||
},
|
||||
spec: { plugin: toBareBuilderPlugin(builder) },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ import type {
|
||||
Querybuildertypesv5QueryRangeRequestDTO,
|
||||
Querybuildertypesv5ScalarDataDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
|
||||
Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Querybuildertypesv5QueryEnvelopeClickHouseSQLDTOType } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { isBuilderEnvelope } from './builderEnvelope';
|
||||
import type { PanelTable, PanelTableColumn } from './types';
|
||||
|
||||
// Narrow view over a builder-query aggregation; envelope spec is `unknown`, so naming reads
|
||||
@@ -28,10 +26,7 @@ export function extractAggregationsPerQuery(
|
||||
): AggregationsPerQuery {
|
||||
const perQuery: AggregationsPerQuery = {};
|
||||
(requestPayload?.compositeQuery?.queries ?? []).forEach((envelope) => {
|
||||
if (
|
||||
envelope.type !==
|
||||
Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query
|
||||
) {
|
||||
if (!isBuilderEnvelope(envelope)) {
|
||||
return;
|
||||
}
|
||||
const spec = envelope.spec;
|
||||
|
||||
1
go.mod
1
go.mod
@@ -57,7 +57,6 @@ require (
|
||||
github.com/segmentio/analytics-go/v3 v3.2.1
|
||||
github.com/sethvargo/go-password v0.2.0
|
||||
github.com/smartystreets/goconvey v1.8.1
|
||||
github.com/soheilhy/cmux v0.1.5
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggest/jsonschema-go v0.3.78
|
||||
|
||||
3
go.sum
3
go.sum
@@ -1057,8 +1057,6 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
|
||||
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
@@ -1493,7 +1491,6 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package apiserver
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type APIServer interface {
|
||||
// APIServer is a long running service serving the SigNoz API.
|
||||
factory.ServiceWithHealthy
|
||||
|
||||
// Returns the mux router for the API server. Primarily used for collecting OpenAPI operations.
|
||||
Router() *mux.Router
|
||||
|
||||
|
||||
@@ -3,13 +3,16 @@ package apiserver
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
httpserver "github.com/SigNoz/signoz/pkg/http/server"
|
||||
)
|
||||
|
||||
// Config holds the configuration for config.
|
||||
type Config struct {
|
||||
Timeout Timeout `mapstructure:"timeout"`
|
||||
Logging Logging `mapstructure:"logging"`
|
||||
httpserver.Config `mapstructure:",squash" yaml:",squash"`
|
||||
Timeout Timeout `mapstructure:"timeout"`
|
||||
Logging Logging `mapstructure:"logging"`
|
||||
}
|
||||
|
||||
type Timeout struct {
|
||||
@@ -32,6 +35,10 @@ func NewConfigFactory() factory.ConfigFactory {
|
||||
|
||||
func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Config: httpserver.Config{
|
||||
Address: "0.0.0.0:8080",
|
||||
ReadTimeout: 60 * time.Second,
|
||||
},
|
||||
Timeout: Timeout{
|
||||
Default: 60 * time.Second,
|
||||
Max: 600 * time.Second,
|
||||
@@ -52,5 +59,9 @@ func newConfig() factory.Config {
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Address == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "apiserver.address is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,14 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
"github.com/SigNoz/signoz/pkg/config/envprovider"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
httpserver "github.com/SigNoz/signoz/pkg/http/server"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewWithEnvProvider(t *testing.T) {
|
||||
t.Setenv("SIGNOZ_APISERVER_ADDRESS", "0.0.0.0:9090")
|
||||
t.Setenv("SIGNOZ_APISERVER_READ__TIMEOUT", "80s")
|
||||
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_DEFAULT", "70s")
|
||||
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_MAX", "700s")
|
||||
t.Setenv("SIGNOZ_APISERVER_TIMEOUT_EXCLUDED__ROUTES", "/excluded1,/excluded2")
|
||||
@@ -38,6 +41,10 @@ func TestNewWithEnvProvider(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := &Config{
|
||||
Config: httpserver.Config{
|
||||
Address: "0.0.0.0:9090",
|
||||
ReadTimeout: 80 * time.Second,
|
||||
},
|
||||
Timeout: Timeout{
|
||||
Default: 70 * time.Second,
|
||||
Max: 700 * time.Second,
|
||||
|
||||
@@ -2,9 +2,11 @@ package signozapiserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apiserver"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
@@ -12,6 +14,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
httpserver "github.com/SigNoz/signoz/pkg/http/server"
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
@@ -37,23 +41,26 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/subscription"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
config apiserver.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
globalConfig global.Config
|
||||
web web.Web
|
||||
router *mux.Router
|
||||
httpServer *httpserver.Server
|
||||
healthyC chan struct{}
|
||||
authzMiddleware *middleware.AuthZ
|
||||
authzService authz.AuthZ
|
||||
orgHandler organization.Handler
|
||||
userHandler user.Handler
|
||||
userGetter user.Getter
|
||||
sessionHandler session.Handler
|
||||
authDomainHandler authdomain.Handler
|
||||
authDomainModule authdomain.Module
|
||||
@@ -98,7 +105,6 @@ func NewFactory(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
userGetter user.Getter,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
@@ -134,6 +140,11 @@ func NewFactory(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
identNResolver identn.IdentNResolver,
|
||||
sharder sharder.Sharder,
|
||||
auditor auditor.Auditor,
|
||||
web web.Web,
|
||||
quickFilterModule quickfilter.Module,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
@@ -146,7 +157,6 @@ func NewFactory(
|
||||
authzService,
|
||||
orgHandler,
|
||||
userHandler,
|
||||
userGetter,
|
||||
sessionHandler,
|
||||
authDomainHandler,
|
||||
authDomainModule,
|
||||
@@ -182,6 +192,11 @@ func NewFactory(
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
globalConfig,
|
||||
identNResolver,
|
||||
sharder,
|
||||
auditor,
|
||||
web,
|
||||
quickFilterModule,
|
||||
quickFilterHandler,
|
||||
)
|
||||
@@ -196,7 +211,6 @@ func newProvider(
|
||||
authzService authz.AuthZ,
|
||||
orgHandler organization.Handler,
|
||||
userHandler user.Handler,
|
||||
userGetter user.Getter,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
@@ -232,6 +246,11 @@ func newProvider(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
globalConfig global.Config,
|
||||
identNResolver identn.IdentNResolver,
|
||||
sharder sharder.Sharder,
|
||||
auditor auditor.Auditor,
|
||||
web web.Web,
|
||||
quickFilterModule quickfilter.Module,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
@@ -239,12 +258,12 @@ func newProvider(
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
|
||||
provider := &provider{
|
||||
config: config,
|
||||
settings: settings,
|
||||
globalConfig: globalConfig,
|
||||
web: web,
|
||||
router: router,
|
||||
healthyC: make(chan struct{}),
|
||||
orgHandler: orgHandler,
|
||||
userHandler: userHandler,
|
||||
userGetter: userGetter,
|
||||
authzService: authzService,
|
||||
sessionHandler: sessionHandler,
|
||||
authDomainHandler: authDomainHandler,
|
||||
@@ -287,13 +306,68 @@ func newProvider(
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
|
||||
router.Use(middleware.NewRecovery(settings.Logger()).Wrap)
|
||||
router.Use(middleware.NewOtel("apiserver", providerSettings.MeterProvider, providerSettings.TracerProvider).Wrap)
|
||||
router.Use(middleware.NewIdentN(identNResolver, sharder, settings.Logger()).Wrap)
|
||||
router.Use(middleware.NewTimeout(settings.Logger(),
|
||||
config.Timeout.ExcludedRoutes,
|
||||
config.Timeout.Default,
|
||||
config.Timeout.Max,
|
||||
).Wrap)
|
||||
router.Use(middleware.NewResource(settings.Logger()).Wrap)
|
||||
router.Use(middleware.NewAudit(settings.Logger(), config.Logging.ExcludedRoutes, auditor).Wrap)
|
||||
router.Use(middleware.NewComment().Wrap)
|
||||
|
||||
if err := provider.AddToRouter(router); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpHandler := middleware.NewCors().Wrap(router)
|
||||
httpHandler = middleware.NewCompress().Wrap(httpHandler)
|
||||
|
||||
routePrefix := globalConfig.ExternalPath()
|
||||
if routePrefix != "" {
|
||||
prefixed := http.StripPrefix(routePrefix, httpHandler)
|
||||
httpHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
|
||||
router.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
prefixed.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
httpServer, err := httpserver.New(settings.Logger(), config.Config, httpHandler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
provider.httpServer = httpServer
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Start(ctx context.Context) error {
|
||||
// Mount the web routes last so the catch-all prefix does not shadow API
|
||||
// routes registered on the router after construction.
|
||||
if err := provider.web.AddToRouter(provider.router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
close(provider.healthyC)
|
||||
|
||||
return provider.httpServer.Start(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) Stop(ctx context.Context) error {
|
||||
return provider.httpServer.Stop(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) Healthy() <-chan struct{} {
|
||||
return provider.healthyC
|
||||
}
|
||||
|
||||
func (provider *provider) Router() *mux.Router {
|
||||
return provider.router
|
||||
}
|
||||
|
||||
@@ -6,35 +6,24 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/users", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.ListUsers, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListUsers",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List users v2",
|
||||
Description: "This endpoint lists all users for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
|
||||
ID: "ListUsers",
|
||||
Tags: []string{"users"},
|
||||
Summary: "List users v2",
|
||||
Description: "This endpoint lists all users for the organization",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -72,43 +61,20 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user",
|
||||
Description: "This endpoint creates a user for the organization",
|
||||
Request: new(authtypes.PostableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
|
||||
TargetSelector: provider.roleSelector,
|
||||
OptionalTargets: true,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
|
||||
ID: "CreateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user",
|
||||
Description: "This endpoint creates a user for the organization",
|
||||
Request: new(authtypes.PostableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -129,148 +95,88 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user by user id",
|
||||
Description: "This endpoint returns the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
|
||||
ID: "GetUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user by user id",
|
||||
Description: "This endpoint returns the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserWithRoles),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.UpdateUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update user v2",
|
||||
Description: "This endpoint updates the user by id",
|
||||
Request: new(types.UpdatableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
|
||||
ID: "UpdateUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Update user v2",
|
||||
Description: "This endpoint updates the user by id",
|
||||
Request: new(types.UpdatableUser),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUser, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user",
|
||||
Description: "This endpoint deletes the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
|
||||
ID: "DeleteUser",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user",
|
||||
Description: "This endpoint deletes the user by id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetResetPasswordToken, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get reset password token for a user",
|
||||
Description: "This endpoint returns the existing reset password token for a user.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
|
||||
ID: "GetResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get reset password token for a user",
|
||||
Description: "This endpoint returns the existing reset password token for a user.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.CreateResetPasswordToken, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create or regenerate reset password token for a user",
|
||||
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachParentChildResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ParentResource: coretypes.ResourceUser,
|
||||
ParentID: coretypes.PathParam("id"),
|
||||
ParentSelector: coretypes.IDSelector,
|
||||
ChildResource: coretypes.ResourceMetaResourceFactorPassword,
|
||||
ChildIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
|
||||
ID: "CreateResetPasswordToken",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create or regenerate reset password token for a user",
|
||||
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(types.ResetPasswordToken),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -342,196 +248,90 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetRolesByUserID, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetRolesByUserID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user roles",
|
||||
Description: "This endpoint returns the user roles by user id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
|
||||
ID: "GetRolesByUserID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user roles",
|
||||
Description: "This endpoint returns the user roles by user id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.Role, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetUsersByRoleID, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetUsersByRoleID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get users by role id",
|
||||
Description: "This endpoint returns the users having the role by role id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceRole,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUsersByRoleID), handler.OpenAPIDef{
|
||||
ID: "GetUsersByRoleID",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get users by role id",
|
||||
Description: "This endpoint returns the users having the role by role id",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*types.User, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUserRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user role",
|
||||
Description: "This endpoint assigns a role to a user",
|
||||
Request: new(authtypes.PostableUserRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("userId")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
|
||||
ID: "CreateUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Create user role",
|
||||
Description: "This endpoint assigns a role to a user",
|
||||
Request: new(authtypes.PostableUserRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.GetUserRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user role",
|
||||
Description: "This endpoint gets an existing user role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserRole),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceUser,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: provider.userRoleUserIDExtractor(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
|
||||
ID: "GetUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Get user role",
|
||||
Description: "This endpoint gets an existing user role",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.UserRole),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUserRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user role",
|
||||
Description: "This endpoint revokes a role from a user",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(provider.userRoleUserIDExtractor()),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(provider.userRoleRoleIDExtractor()),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
|
||||
ID: "DeleteUserRole",
|
||||
Tags: []string{"users"},
|
||||
Summary: "Delete user role",
|
||||
Description: "This endpoint revokes a role from a user",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return userRole.UserID.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
if ec.Request == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return userRole.RoleID.String(), nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,6 +78,40 @@ func NewRegistry(ctx context.Context, logger *slog.Logger, services ...NamedServ
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Add registers additional services into the registry. It must be called before Start.
|
||||
func (registry *Registry) Add(ctx context.Context, services ...NamedService) error {
|
||||
added := make([]*serviceWithState, 0, len(services))
|
||||
for _, s := range services {
|
||||
if _, ok := registry.servicesByName[s.Name()]; ok {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidRegistry, "cannot add service, duplicate service name %q", s.Name())
|
||||
}
|
||||
added = append(added, newServiceWithState(s))
|
||||
}
|
||||
|
||||
for _, ss := range added {
|
||||
registry.services = append(registry.services, ss)
|
||||
registry.servicesByName[ss.service.Name()] = ss
|
||||
}
|
||||
|
||||
for _, ss := range added {
|
||||
for _, dep := range ss.service.DependsOn() {
|
||||
if dep == ss.service.Name() {
|
||||
registry.logger.ErrorContext(ctx, "ignoring self-dependency", slog.Any("service", ss.service.Name()))
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := registry.servicesByName[dep]; !ok {
|
||||
registry.logger.ErrorContext(ctx, "ignoring unknown dependency", slog.Any("service", ss.service.Name()), slog.Any("dependency", dep))
|
||||
continue
|
||||
}
|
||||
|
||||
ss.dependsOn = append(ss.dependsOn, dep)
|
||||
}
|
||||
}
|
||||
|
||||
return detectCyclicDeps(registry.services)
|
||||
}
|
||||
|
||||
func (registry *Registry) Start(ctx context.Context) {
|
||||
for _, ss := range registry.services {
|
||||
go func(ss *serviceWithState) {
|
||||
|
||||
@@ -342,3 +342,61 @@ func TestDependsOnCycleReturnsError(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "dependency cycles detected")
|
||||
}
|
||||
|
||||
func TestRegistryAdd(t *testing.T) {
|
||||
s1 := newTestService(t)
|
||||
s2 := newTestService(t)
|
||||
|
||||
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, registry.Add(context.Background(), NewNamedService(MustNewName("s2"), s2)))
|
||||
|
||||
ctx := context.Background()
|
||||
registry.Start(ctx)
|
||||
|
||||
require.NoError(t, registry.AwaitHealthy(ctx))
|
||||
byState := registry.ServicesByState()
|
||||
assert.Len(t, byState[StateRunning], 2)
|
||||
assert.True(t, registry.IsHealthy())
|
||||
|
||||
assert.NoError(t, registry.Stop(ctx))
|
||||
}
|
||||
|
||||
func TestRegistryAddDuplicateReturnsError(t *testing.T) {
|
||||
s1 := newTestService(t)
|
||||
|
||||
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = registry.Add(context.Background(), NewNamedService(MustNewName("s1"), newTestService(t)))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate service name")
|
||||
}
|
||||
|
||||
func TestRegistryAddWithDependency(t *testing.T) {
|
||||
s1 := newHealthyTestService(t)
|
||||
s2 := newTestService(t)
|
||||
|
||||
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
|
||||
require.NoError(t, err)
|
||||
|
||||
// s2 depends on the already registered s1.
|
||||
require.NoError(t, registry.Add(context.Background(), NewNamedService(MustNewName("s2"), s2, MustNewName("s1"))))
|
||||
|
||||
ctx := context.Background()
|
||||
registry.Start(ctx)
|
||||
|
||||
// s2 stays in STARTING until s1 is healthy.
|
||||
require.Eventually(t, func() bool {
|
||||
byState := registry.ServicesByState()
|
||||
return len(byState[StateStarting]) == 2
|
||||
}, time.Second, time.Millisecond)
|
||||
|
||||
close(s1.healthyC)
|
||||
|
||||
require.NoError(t, registry.AwaitHealthy(ctx))
|
||||
assert.True(t, registry.IsHealthy())
|
||||
|
||||
assert.NoError(t, registry.Stop(ctx))
|
||||
}
|
||||
|
||||
@@ -53,20 +53,9 @@ type AttachDetachSiblingResourceDef struct {
|
||||
TargetResource coretypes.Resource
|
||||
TargetIDs coretypes.ResourceIDsExtractor
|
||||
TargetSelector coretypes.SelectorFunc
|
||||
// OptionalTargets drops the def when no target ids resolve, for routes where
|
||||
// the target list is legitimately optional in the payload.
|
||||
OptionalTargets bool
|
||||
}
|
||||
|
||||
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
|
||||
if def.OptionalTargets && def.TargetIDs.IsPhase(coretypes.PhaseRequest) {
|
||||
// extractors are pure; on error fall through and let fill record it
|
||||
ids, err := def.TargetIDs.Fn(ec)
|
||||
if err == nil && len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return []coretypes.ResolvedResource{
|
||||
coretypes.NewResolvedResourceWithTarget(
|
||||
def.Verb,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func userRoleAttachDef(optionalTargets bool) AttachDetachSiblingResourceDef {
|
||||
return AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceUser,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.NewResourceRole(),
|
||||
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
|
||||
TargetSelector: coretypes.WildcardSelector,
|
||||
OptionalTargets: optionalTargets,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDetachSiblingResourceDefOptionalTargets(t *testing.T) {
|
||||
t.Run("absent target list resolves to no checks", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"name":"jane"}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
assert.Empty(t, resolved)
|
||||
})
|
||||
|
||||
t.Run("empty target list resolves to no checks", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[]}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
assert.Empty(t, resolved)
|
||||
})
|
||||
|
||||
t.Run("present targets resolve the attach as usual", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[{"id":"role-a"},{"id":"role-b"}]}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
require.Len(t, resolved, 1)
|
||||
|
||||
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
|
||||
require.True(t, ok)
|
||||
assert.NoError(t, resolved[0].Err())
|
||||
assert.Equal(t, []string{"role-a", "role-b"}, withTarget.TargetIDs())
|
||||
})
|
||||
|
||||
t.Run("malformed target entry still fails closed", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[{"id":""}]}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
|
||||
require.Len(t, resolved, 1)
|
||||
|
||||
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{""}, withTarget.TargetIDs())
|
||||
})
|
||||
|
||||
t.Run("without the flag the empty-id contract is preserved", func(t *testing.T) {
|
||||
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"name":"jane"}`)}
|
||||
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(false)}, ec)
|
||||
require.Len(t, resolved, 1)
|
||||
|
||||
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{""}, withTarget.TargetIDs())
|
||||
})
|
||||
}
|
||||
17
pkg/http/middleware/compress.go
Normal file
17
pkg/http/middleware/compress.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
gorillahandlers "github.com/gorilla/handlers"
|
||||
)
|
||||
|
||||
type Compress struct{}
|
||||
|
||||
func NewCompress() *Compress {
|
||||
return &Compress{}
|
||||
}
|
||||
|
||||
func (middleware *Compress) Wrap(next http.Handler) http.Handler {
|
||||
return gorillahandlers.CompressHandler(next)
|
||||
}
|
||||
25
pkg/http/middleware/cors.go
Normal file
25
pkg/http/middleware/cors.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/rs/cors"
|
||||
)
|
||||
|
||||
type Cors struct {
|
||||
cors *cors.Cors
|
||||
}
|
||||
|
||||
func NewCors() *Cors {
|
||||
return &Cors{
|
||||
cors: cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (middleware *Cors) Wrap(next http.Handler) http.Handler {
|
||||
return middleware.cors.Handler(next)
|
||||
}
|
||||
43
pkg/http/middleware/otel.go
Normal file
43
pkg/http/middleware/otel.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// defaultExcludedRoutes are the health endpoints kept out of tracing/metrics to
|
||||
// avoid drowning telemetry in probe traffic.
|
||||
var defaultExcludedRoutes = []string{
|
||||
"/api/v1/health",
|
||||
"/api/v2/healthz",
|
||||
"/api/v2/readyz",
|
||||
"/api/v2/livez",
|
||||
}
|
||||
|
||||
type Otel struct {
|
||||
wrap mux.MiddlewareFunc
|
||||
}
|
||||
|
||||
func NewOtel(service string, meterProvider metric.MeterProvider, tracerProvider trace.TracerProvider) *Otel {
|
||||
return &Otel{
|
||||
wrap: otelmux.Middleware(
|
||||
service,
|
||||
otelmux.WithMeterProvider(meterProvider),
|
||||
otelmux.WithTracerProvider(tracerProvider),
|
||||
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
|
||||
otelmux.WithFilter(func(r *http.Request) bool {
|
||||
return !slices.Contains(defaultExcludedRoutes, r.URL.Path)
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (middleware *Otel) Wrap(next http.Handler) http.Handler {
|
||||
return middleware.wrap(next)
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
package server
|
||||
|
||||
import "time"
|
||||
|
||||
// Config holds the configuration for http.
|
||||
type Config struct {
|
||||
//Address specifies the TCP address for the server to listen on, in the form "host:port".
|
||||
// If empty, ":http" (port 80) is used. The service names are defined in RFC 6335 and assigned by IANA.
|
||||
// See net.Dial for details of the address format.
|
||||
Address string `mapstructure:"address"`
|
||||
|
||||
// ReadTimeout bounds reading an entire request, including the body. Zero means no timeout.
|
||||
ReadTimeout time.Duration `mapstructure:"read_timeout"`
|
||||
|
||||
// WriteTimeout bounds writing the response. Zero means no timeout, required for
|
||||
// streaming endpoints that hold the connection open.
|
||||
WriteTimeout time.Duration `mapstructure:"write_timeout"`
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error)
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Address,
|
||||
Handler: handler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
nethttppprof "net/http/pprof"
|
||||
runtimepprof "runtime/pprof"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
httpserver "github.com/SigNoz/signoz/pkg/http/server"
|
||||
@@ -23,7 +24,7 @@ func NewFactory() factory.ProviderFactory[pprof.PProf, pprof.Config] {
|
||||
func New(_ context.Context, settings factory.ProviderSettings, config pprof.Config) (pprof.PProf, error) {
|
||||
server, err := httpserver.New(
|
||||
settings.Logger.With(slog.String("pkg", "github.com/SigNoz/signoz/pkg/pprof/httppprof")),
|
||||
httpserver.Config{Address: config.Address},
|
||||
httpserver.Config{Address: config.Address, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second},
|
||||
newHandler(),
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -4070,20 +4070,20 @@ func (aH *APIHandler) RegisterTraceFunnelsRoutes(router *mux.Router, am *middlew
|
||||
Methods(http.MethodPut)
|
||||
|
||||
// Analytics endpoints
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", aH.handleValidateTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", aH.handleFunnelAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", aH.handleStepAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps/overview", aH.handleFunnelStepAnalytics).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", aH.handleFunnelSlowTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", aH.handleFunnelErrorTraces).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/validate", am.ViewAccess(aH.handleValidateTraces)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/overview", am.ViewAccess(aH.handleFunnelAnalytics)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps", am.ViewAccess(aH.handleStepAnalytics)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/steps/overview", am.ViewAccess(aH.handleFunnelStepAnalytics)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/slow-traces", am.ViewAccess(aH.handleFunnelSlowTraces)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/{funnel_id}/analytics/error-traces", am.ViewAccess(aH.handleFunnelErrorTraces)).Methods("POST")
|
||||
|
||||
// Analytics endpoints
|
||||
traceFunnelsRouter.HandleFunc("/analytics/validate", aH.handleValidateTracesWithPayload).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/overview", aH.handleFunnelAnalyticsWithPayload).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/steps", aH.handleStepAnalyticsWithPayload).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/steps/overview", aH.handleFunnelStepAnalyticsWithPayload).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/slow-traces", aH.handleFunnelSlowTracesWithPayload).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/error-traces", aH.handleFunnelErrorTracesWithPayload).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/validate", am.ViewAccess(aH.handleValidateTracesWithPayload)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/overview", am.ViewAccess(aH.handleFunnelAnalyticsWithPayload)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/steps", am.ViewAccess(aH.handleStepAnalyticsWithPayload)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/steps/overview", am.ViewAccess(aH.handleFunnelStepAnalyticsWithPayload)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/slow-traces", am.ViewAccess(aH.handleFunnelSlowTracesWithPayload)).Methods("POST")
|
||||
traceFunnelsRouter.HandleFunc("/analytics/error-traces", am.ViewAccess(aH.handleFunnelErrorTracesWithPayload)).Methods("POST")
|
||||
}
|
||||
|
||||
func (aH *APIHandler) handleValidateTraces(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -2,19 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
"github.com/rs/cors"
|
||||
"github.com/soheilhy/cmux"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
@@ -23,31 +13,15 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
)
|
||||
|
||||
// Server runs HTTP, Mux and a grpc server
|
||||
// Server runs auxiliary servers (opamp) alongside the signoz apiserver
|
||||
type Server struct {
|
||||
config signoz.Config
|
||||
signoz *signoz.SigNoz
|
||||
|
||||
// public http router
|
||||
httpConn net.Listener
|
||||
httpServer *http.Server
|
||||
httpHostPort string
|
||||
|
||||
opampServer *opamp.Server
|
||||
|
||||
unavailableChannel chan healthcheck.Status
|
||||
}
|
||||
|
||||
// NewServer creates and initializes Server
|
||||
@@ -90,20 +64,20 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
config: config,
|
||||
signoz: signoz,
|
||||
httpHostPort: constants.HTTPHostPort,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
}
|
||||
// Register the legacy query-service routes on the apiserver router. The
|
||||
// apiserver owns the HTTP server and applies the middleware chain at serve
|
||||
// time, so these routes get the same treatment as the apiserver routes.
|
||||
r := signoz.APIServer.Router()
|
||||
am := middleware.NewAuthZ(signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter, signoz.Authz)
|
||||
|
||||
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.httpServer = httpServer
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
opAmpModel.Init(signoz.SQLStore, signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter)
|
||||
|
||||
@@ -121,6 +95,8 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{}
|
||||
|
||||
s.opampServer = opamp.InitializeServer(
|
||||
&opAmpModel.AllAgents,
|
||||
agentConfMgr,
|
||||
@@ -130,146 +106,18 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HealthCheckStatus returns health check status channel a client can subscribe to
|
||||
func (s Server) HealthCheckStatus() chan healthcheck.Status {
|
||||
return s.unavailableChannel
|
||||
}
|
||||
|
||||
func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server, error) {
|
||||
r := NewRouter()
|
||||
|
||||
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(otelmux.Middleware(
|
||||
"apiserver",
|
||||
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
|
||||
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
|
||||
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
|
||||
otelmux.WithFilter(func(r *http.Request) bool {
|
||||
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
|
||||
}),
|
||||
))
|
||||
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
|
||||
s.config.APIServer.Timeout.ExcludedRoutes,
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
|
||||
|
||||
api.RegisterRoutes(r, am)
|
||||
api.RegisterLogsRoutes(r, am)
|
||||
api.RegisterIntegrationRoutes(r, am)
|
||||
api.RegisterQueryRangeV3Routes(r, am)
|
||||
api.RegisterQueryRangeV4Routes(r, am)
|
||||
api.RegisterMessagingQueuesRoutes(r, am)
|
||||
api.RegisterThirdPartyApiRoutes(r, am)
|
||||
api.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
err := s.signoz.APIServer.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
handler = handlers.CompressHandler(handler)
|
||||
|
||||
err = web.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routePrefix := s.config.Global.ExternalPath()
|
||||
if routePrefix != "" {
|
||||
prefixed := http.StripPrefix(routePrefix, handler)
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
|
||||
r.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
prefixed.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initListeners initialises listeners of the server
|
||||
func (s *Server) initListeners() error {
|
||||
// listen on public port
|
||||
var err error
|
||||
publicHostPort := s.httpHostPort
|
||||
if publicHostPort == "" {
|
||||
return fmt.Errorf("constants.HTTPHostPort is required")
|
||||
}
|
||||
|
||||
s.httpConn, err = net.Listen("tcp", publicHostPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
// Start starts the opamp websocket server. The HTTP API server is started by
|
||||
// the signoz registry.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
slog.Info("Starting OpAmp Websocket server", "addr", constants.OpAmpWsEndpoint)
|
||||
if err := s.opampServer.Start(constants.OpAmpWsEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var httpPort int
|
||||
if port, err := utils.GetPort(s.httpConn.Addr()); err == nil {
|
||||
httpPort = port
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting HTTP server", "port", httpPort, "addr", s.httpHostPort)
|
||||
|
||||
switch err := s.httpServer.Serve(s.httpConn); err {
|
||||
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
|
||||
// normal exit, nothing to do
|
||||
default:
|
||||
slog.Error("Could not start HTTP server", errors.Attr(err))
|
||||
}
|
||||
s.unavailableChannel <- healthcheck.Unavailable
|
||||
}()
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting OpAmp Websocket server", "addr", constants.OpAmpWsEndpoint)
|
||||
err := s.opampServer.Start(constants.OpAmpWsEndpoint)
|
||||
if err != nil {
|
||||
slog.Error("opamp ws server failed to start", errors.Attr(err))
|
||||
s.unavailableChannel <- healthcheck.Unavailable
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if s.httpServer != nil {
|
||||
if err := s.httpServer.Shutdown(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.opampServer.Stop()
|
||||
|
||||
return nil
|
||||
|
||||
@@ -10,11 +10,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
HTTPHostPort = "0.0.0.0:8080" // Address to serve http (query service)
|
||||
PrivateHostPort = "0.0.0.0:8085" // Address to server internal services like alert manager
|
||||
OpAmpWsEndpoint = "0.0.0.0:4320" // address for opamp websocket
|
||||
)
|
||||
const OpAmpWsEndpoint = "0.0.0.0:4320" // address for opamp websocket
|
||||
|
||||
const MaxAllowedPointsInTimeSeries = 300
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package healthcheck
|
||||
|
||||
const (
|
||||
// Unavailable indicates the service is not able to handle requests
|
||||
Unavailable Status = iota
|
||||
// Ready indicates the service is ready to handle requests
|
||||
Ready
|
||||
// Broken indicates that the healthcheck itself is broken, not serving HTTP
|
||||
Broken
|
||||
)
|
||||
|
||||
type Status int
|
||||
@@ -10,12 +10,14 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/apiserver"
|
||||
"github.com/SigNoz/signoz/pkg/apiserver/signozapiserver"
|
||||
"github.com/SigNoz/signoz/pkg/auditor"
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/gateway"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/identn"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
|
||||
@@ -42,9 +44,11 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/sharder"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/subscription"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
"github.com/swaggest/openapi-go"
|
||||
@@ -66,7 +70,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ authz.AuthZ }{},
|
||||
struct{ organization.Handler }{},
|
||||
struct{ user.Handler }{},
|
||||
struct{ user.Getter }{},
|
||||
struct{ session.Handler }{},
|
||||
struct{ authdomain.Handler }{},
|
||||
struct{ authdomain.Module }{},
|
||||
@@ -102,6 +105,11 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
global.Config{},
|
||||
struct{ identn.IdentNResolver }{},
|
||||
struct{ sharder.Sharder }{},
|
||||
struct{ auditor.Auditor }{},
|
||||
struct{ web.Web }{},
|
||||
struct{ quickfilter.Module }{},
|
||||
struct{ quickfilter.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
|
||||
@@ -254,7 +254,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
|
||||
sqlmigration.NewAddUserTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -320,14 +319,13 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
|
||||
)
|
||||
}
|
||||
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config, gatewayService gateway.Gateway, identNResolver identn.IdentNResolver, sharder sharder.Sharder, auditor auditor.Auditor, web web.Web) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozapiserver.NewFactory(
|
||||
orgGetter,
|
||||
authz,
|
||||
implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
|
||||
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
|
||||
modules.UserGetter,
|
||||
implsession.NewHandler(modules.Session, globalConfig),
|
||||
implauthdomain.NewHandler(modules.AuthDomain),
|
||||
modules.AuthDomain,
|
||||
@@ -363,6 +361,11 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
globalConfig,
|
||||
identNResolver,
|
||||
sharder,
|
||||
auditor,
|
||||
web,
|
||||
modules.QuickFilter,
|
||||
handlers.QuickFilter,
|
||||
),
|
||||
|
||||
@@ -102,6 +102,10 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
Handlers{},
|
||||
global.Config{},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -635,13 +635,20 @@ func New(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.APIServer,
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway),
|
||||
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global, gateway, identNResolver, sharder, auditor, web),
|
||||
"signoz",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register the API server with the registry so its lifecycle is managed
|
||||
// alongside the other services and it shows up in the health endpoint.
|
||||
err = registry.Add(ctx, factory.NewNamedService(factory.MustNewName("apiserver"), apiserverInstance))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SigNoz{
|
||||
Registry: registry,
|
||||
Analytics: analytics,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addUserTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddUserTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_user_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addUserTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addUserTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addUserTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// user and factor-password moved from the legacy AdminAccess role gate to
|
||||
// CheckResources, which on enterprise requires real tuples -- existing orgs
|
||||
// never had these written, only new orgs get them from the registry at
|
||||
// bootstrap. The managed-role transaction groups stored per org already
|
||||
// carry these transactions, so no re-sync is needed here.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "delete"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "attach"},
|
||||
{authtypes.SigNozAdminRoleName, "user", "user", "detach"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addUserTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -141,6 +141,7 @@ func (d *DashboardSpec) validateQuery(qi int, q Query, panelKind PanelPluginKind
|
||||
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
|
||||
compositeSubQueryTypeToPluginKind := map[qb.QueryType]QueryPluginKind{
|
||||
qb.QueryTypeBuilder: QueryKindBuilder,
|
||||
qb.QueryTypeBuilderAI: QueryKindAIBuilder,
|
||||
qb.QueryTypeFormula: QueryKindFormula,
|
||||
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
|
||||
qb.QueryTypePromQL: QueryKindPromQL,
|
||||
|
||||
@@ -117,6 +117,22 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// An AI builder query is always a traces query, so it counts towards traces.
|
||||
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
|
||||
aiBuilder := `{
|
||||
"kind": "time_series",
|
||||
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
|
||||
}`
|
||||
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
|
||||
|
||||
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
|
||||
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelCount])
|
||||
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
|
||||
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
|
||||
}
|
||||
|
||||
// promql and clickhouse queries carry no signal, so they land in the panel total
|
||||
// and nowhere else.
|
||||
func TestNewStatsFromStorableDashboardsIgnoresSignallessQueries(t *testing.T) {
|
||||
|
||||
@@ -1638,6 +1638,8 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"TimeSeries+PromQL", mkQuery("signoz/TimeSeriesPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), false},
|
||||
{"Table+ClickHouse", mkQuery("signoz/TablePanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
@@ -1647,6 +1649,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
|
||||
@@ -89,6 +89,7 @@ type QueryPlugin struct {
|
||||
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(QueryKindBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec"),
|
||||
string(QueryKindAIBuilder): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec"),
|
||||
string(QueryKindComposite): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery"),
|
||||
string(QueryKindFormula): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula"),
|
||||
string(QueryKindPromQL): schemaRef("DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery"),
|
||||
@@ -118,6 +119,7 @@ func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
|
||||
func (QueryPlugin) JSONSchemaOneOf() []any {
|
||||
return []any{
|
||||
QueryPluginVariant[BuilderQuerySpec]{Kind: string(QueryKindBuilder)},
|
||||
QueryPluginVariant[AIBuilderQuerySpec]{Kind: string(QueryKindAIBuilder)},
|
||||
QueryPluginVariant[CompositeQuerySpec]{Kind: string(QueryKindComposite)},
|
||||
QueryPluginVariant[FormulaSpec]{Kind: string(QueryKindFormula)},
|
||||
QueryPluginVariant[PromQLQuerySpec]{Kind: string(QueryKindPromQL)},
|
||||
@@ -138,6 +140,11 @@ func (plugin QueryPlugin) buildV5CompositeQueryFromPlugin() (qb.CompositeQuery,
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "builder query is empty")
|
||||
}
|
||||
return wrapEnvelope(qb.QueryTypeBuilder, spec.Spec), nil
|
||||
case *AIBuilderQuerySpec:
|
||||
if spec == nil {
|
||||
return qb.CompositeQuery{}, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "AI builder query is empty")
|
||||
}
|
||||
return wrapEnvelope(qb.QueryTypeBuilderAI, qb.QueryBuilderQuery[qb.TraceAggregation](*spec)), nil
|
||||
case *qb.PromQuery:
|
||||
return wrapEnvelope(qb.QueryTypePromQL, *spec), nil
|
||||
case *qb.ClickHouseQuery:
|
||||
@@ -231,6 +238,7 @@ var (
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
QueryKindAIBuilder: func() any { return new(AIBuilderQuerySpec) },
|
||||
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
|
||||
QueryKindFormula: func() any { return new(FormulaSpec) },
|
||||
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
|
||||
@@ -243,13 +251,13 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -106,6 +106,13 @@ func redactQuery(spec any) any {
|
||||
return spec
|
||||
}
|
||||
return &BuilderQuerySpec{Spec: redactLeafQuery(s.Spec)}
|
||||
case *AIBuilderQuerySpec:
|
||||
if s == nil {
|
||||
return spec
|
||||
}
|
||||
redacted := redactLeafQuery(qb.QueryBuilderQuery[qb.TraceAggregation](*s)).(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
out := AIBuilderQuerySpec(redacted)
|
||||
return &out
|
||||
case *qb.PromQuery:
|
||||
return redactQueryPtr(s)
|
||||
case *qb.ClickHouseQuery:
|
||||
|
||||
@@ -159,6 +159,11 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
plugin QueryPlugin
|
||||
expectedType qb.QueryType
|
||||
}{
|
||||
{
|
||||
description: "AI builder query",
|
||||
plugin: QueryPlugin{Kind: QueryKindAIBuilder, Spec: &AIBuilderQuerySpec{Name: "A"}},
|
||||
expectedType: qb.QueryTypeBuilderAI,
|
||||
},
|
||||
{
|
||||
description: "promql",
|
||||
plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &qb.PromQuery{Name: "A", Query: "up"}},
|
||||
|
||||
@@ -133,6 +133,19 @@ func TestRedactQueryPluginWrappers(t *testing.T) {
|
||||
assert.Equal(t, "A", builder.Name)
|
||||
})
|
||||
|
||||
t.Run("AI builder plugin pointer is redacted and stays a pointer", func(t *testing.T) {
|
||||
plugin := &AIBuilderQuerySpec{
|
||||
Name: "A",
|
||||
Filter: &qb.Filter{Expression: "body contains 'secret'"},
|
||||
}
|
||||
|
||||
result, ok := redactQuery(plugin).(*AIBuilderQuerySpec)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Nil(t, result.Filter)
|
||||
assert.Equal(t, "A", result.Name)
|
||||
})
|
||||
|
||||
t.Run("composite plugin redacts every sub-query envelope", func(t *testing.T) {
|
||||
composite := &qb.CompositeQuery{Queries: []qb.QueryEnvelope{
|
||||
{Type: qb.QueryTypeBuilder, Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A", Filter: &qb.Filter{Expression: "x = 1"}}},
|
||||
|
||||
@@ -93,6 +93,7 @@ type QueryPluginKind string
|
||||
|
||||
const (
|
||||
QueryKindBuilder QueryPluginKind = "signoz/BuilderQuery"
|
||||
QueryKindAIBuilder QueryPluginKind = "signoz/AIBuilderQuery"
|
||||
QueryKindComposite QueryPluginKind = "signoz/CompositeQuery"
|
||||
QueryKindFormula QueryPluginKind = "signoz/Formula"
|
||||
QueryKindPromQL QueryPluginKind = "signoz/PromQLQuery"
|
||||
@@ -101,7 +102,7 @@ const (
|
||||
)
|
||||
|
||||
func (QueryPluginKind) Enum() []any {
|
||||
return []any{QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
|
||||
return []any{QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL, QueryKindTraceOperator}
|
||||
}
|
||||
|
||||
type (
|
||||
@@ -159,6 +160,26 @@ func (BuilderQuerySpec) JSONSchemaOneOf() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// AIBuilderQuerySpec is the spec of a signoz/AIBuilderQuery plugin: a gen_ai-scoped
|
||||
// (AI observability) traces builder query, executed as qb.QueryTypeBuilderAI. The
|
||||
// signal is implied by the kind and pinned to traces, mirroring the builder_ai_query
|
||||
// QueryEnvelope decode.
|
||||
type AIBuilderQuerySpec qb.QueryBuilderQuery[qb.TraceAggregation]
|
||||
|
||||
func (b *AIBuilderQuerySpec) UnmarshalJSON(data []byte) error {
|
||||
var spec qb.QueryBuilderQuery[qb.TraceAggregation]
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid AI builder query spec")
|
||||
}
|
||||
spec.Signal = telemetrytypes.SignalTraces
|
||||
*b = AIBuilderQuerySpec(spec)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return (qb.QueryBuilderQuery[qb.TraceAggregation]{}).PrepareJSONSchema(s)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// SigNoz panel plugin specs
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
75
pkg/types/dashboardtypes/perses_signoz_plugins_test.go
Normal file
75
pkg/types/dashboardtypes/perses_signoz_plugins_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An AI builder query is a gen_ai-scoped traces builder query: the signal is
|
||||
// implied by the plugin kind and pinned to traces on decode, mirroring the
|
||||
// builder_ai_query QueryEnvelope.
|
||||
func TestAIBuilderQueryPluginRoundTrip(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {"p1": {"kind": "Panel", "spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {
|
||||
"name": "A", "aggregations": [{"expression": "count()"}]
|
||||
}}}}]
|
||||
}}},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
plugin := spec.Panels["p1"].Spec.Queries[0].Spec.Plugin
|
||||
assert.Equal(t, QueryKindAIBuilder, plugin.Kind)
|
||||
|
||||
aiSpec, ok := plugin.Spec.(*AIBuilderQuerySpec)
|
||||
require.True(t, ok, "expected *AIBuilderQuerySpec, got %T", plugin.Spec)
|
||||
assert.Equal(t, "A", aiSpec.Name)
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, aiSpec.Signal)
|
||||
|
||||
// Marshal emits the pinned signal and decodes back to the same plugin.
|
||||
out, err := json.Marshal(plugin)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(out), `"kind":"signoz/AIBuilderQuery"`)
|
||||
assert.Contains(t, string(out), `"signal":"traces"`)
|
||||
|
||||
var roundTripped QueryPlugin
|
||||
require.NoError(t, json.Unmarshal(out, &roundTripped))
|
||||
assert.Equal(t, plugin, roundTripped)
|
||||
}
|
||||
|
||||
// At query-range time an AI builder query wraps into a single builder_ai_query
|
||||
// envelope so the querier routes it to the gen_ai statement builder.
|
||||
func TestAIBuilderQueryPluginBuildsBuilderAIEnvelope(t *testing.T) {
|
||||
plugin := QueryPlugin{Kind: QueryKindAIBuilder, Spec: &AIBuilderQuerySpec{Name: "A", Signal: telemetrytypes.SignalTraces}}
|
||||
|
||||
composite, err := plugin.buildV5CompositeQueryFromPlugin()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, composite.Queries, 1)
|
||||
assert.Equal(t, qb.QueryTypeBuilderAI, composite.Queries[0].Type)
|
||||
|
||||
spec, ok := composite.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
require.True(t, ok, "expected traces builder query, got %T", composite.Queries[0].Spec)
|
||||
assert.Equal(t, "A", spec.Name)
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
|
||||
}
|
||||
|
||||
func TestAIBuilderQueryPluginNilSpec(t *testing.T) {
|
||||
plugin := QueryPlugin{Kind: QueryKindAIBuilder, Spec: (*AIBuilderQuerySpec)(nil)}
|
||||
|
||||
_, err := plugin.buildV5CompositeQueryFromPlugin()
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
|
||||
}
|
||||
Reference in New Issue
Block a user