mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 14:00:42 +01:00
Compare commits
45 Commits
issue_5329
...
feat/alert
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339a10217e | ||
|
|
cd0eb62734 | ||
|
|
ad68a1b991 | ||
|
|
f9294367b1 | ||
|
|
a667007991 | ||
|
|
208fc1e8ec | ||
|
|
54eb67392a | ||
|
|
0e0d97c16f | ||
|
|
1419e03ec1 | ||
|
|
659f9c7ecf | ||
|
|
ce8442c17a | ||
|
|
7ab21678b5 | ||
|
|
951bfb66cd | ||
|
|
1c1a5dc544 | ||
|
|
113685fbdb | ||
|
|
b9c306dd26 | ||
|
|
c9ae10b1c0 | ||
|
|
7e71d4507c | ||
|
|
e13cb08097 | ||
|
|
84a802edba | ||
|
|
f78bd492d8 | ||
|
|
1e7aaa9f14 | ||
|
|
99dcd79979 | ||
|
|
09ab9f4081 | ||
|
|
f945b5f513 | ||
|
|
8b0ab0ff26 | ||
|
|
f2679a6866 | ||
|
|
253a117849 | ||
|
|
c9538be38f | ||
|
|
be51c37317 | ||
|
|
66796d1767 | ||
|
|
8e19b17855 | ||
|
|
8885497e14 | ||
|
|
626e047485 | ||
|
|
cc3d86c3ec | ||
|
|
0100083284 | ||
|
|
648e945471 | ||
|
|
7323d5ae0b | ||
|
|
578b9172ba | ||
|
|
2110006d17 | ||
|
|
01dda19d17 | ||
|
|
b519574b89 | ||
|
|
629ecabce7 | ||
|
|
131e302c55 | ||
|
|
239c92ba67 |
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.
|
||||
12
.claude/rules/go-test.md
Normal file
12
.claude/rules/go-test.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*_test.go"
|
||||
---
|
||||
|
||||
# Go tests
|
||||
|
||||
- **testify + table-driven.** Use `assert` / `require`; prefer table-driven cases. Tests live next to the source file.
|
||||
- **`require` vs `assert`.** `require` for anything the rest of the test cannot proceed without — setup, `require.NoError(t, err)`, nil/length checks before indexing or dereferencing. `assert` for the actual expectations, so one failed check still reports the rest.
|
||||
- **Mock with mockery.** When an interface needs mocking, list it in `.mockery.yml` and run `mockery`; never hand-write mocks. Generated mocks live in the source package's `<pkg>test` sibling (e.g. `resourcestest.NewMockAdapter(t)`).
|
||||
- **Table format.** Declare cases as `testCases := []struct{ name string; ... }` and iterate with `for _, testCase := range testCases { t.Run(testCase.name, ...) }` — the variables are named `testCases` / `testCase`. Case names are PascalCase segments joined by `_`, one segment per aspect (scenario, condition, expectation): `TimestampNotNullNoDefault`, `DropPrimaryKeyConstraint_AlterColumnNullable`, `ForeignKeyConstraint_DoesNotExist_SCreateAndDropConstraintTrue`.
|
||||
- **No hoisted test constants.** When goconst flags a repeated literal in a test, vary the fixture strings across cases instead of hoisting a constant — never introduce a shared const for test data.
|
||||
@@ -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,18 @@ 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
|
||||
# tls:
|
||||
# enabled: true
|
||||
# cert_file: /path/to/server.crt
|
||||
# key_file: /path/to/server.key
|
||||
# # Minimum TLS version: "1.2" or "1.3". Defaults to "1.2".
|
||||
# min_version: "1.2"
|
||||
timeout:
|
||||
# Default request timeout.
|
||||
default: 60s
|
||||
|
||||
@@ -8616,6 +8616,98 @@ components:
|
||||
message:
|
||||
type: string
|
||||
type: object
|
||||
RuletypesLabelPair:
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
type: object
|
||||
RuletypesListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
RuletypesListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
- state
|
||||
- severity
|
||||
type: string
|
||||
RuletypesListableRule:
|
||||
properties:
|
||||
alert:
|
||||
type: string
|
||||
alertType:
|
||||
$ref: '#/components/schemas/RuletypesAlertType'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: string
|
||||
labels:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
ruleType:
|
||||
$ref: '#/components/schemas/RuletypesRuleType'
|
||||
state:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- state
|
||||
- alert
|
||||
- alertType
|
||||
- ruleType
|
||||
type: object
|
||||
RuletypesListableRuleViews:
|
||||
properties:
|
||||
views:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesRuleView'
|
||||
type: array
|
||||
required:
|
||||
- views
|
||||
type: object
|
||||
RuletypesListableRules:
|
||||
properties:
|
||||
labels:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesLabelPair'
|
||||
type: array
|
||||
reservedKeywords:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
rules:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesListableRule'
|
||||
type: array
|
||||
total:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- rules
|
||||
- total
|
||||
- labels
|
||||
- reservedKeywords
|
||||
type: object
|
||||
RuletypesMatchType:
|
||||
enum:
|
||||
- at_least_once
|
||||
@@ -8690,6 +8782,16 @@ components:
|
||||
- ruleType
|
||||
- condition
|
||||
type: object
|
||||
RuletypesPostableRuleView:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRuleViewData'
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- data
|
||||
type: object
|
||||
RuletypesQueryType:
|
||||
enum:
|
||||
- builder
|
||||
@@ -8828,6 +8930,45 @@ components:
|
||||
- promql_rule
|
||||
- anomaly_rule
|
||||
type: string
|
||||
RuletypesRuleView:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRuleViewData'
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- data
|
||||
- orgId
|
||||
type: object
|
||||
RuletypesRuleViewData:
|
||||
properties:
|
||||
order:
|
||||
$ref: '#/components/schemas/RuletypesListOrder'
|
||||
query:
|
||||
type: string
|
||||
sort:
|
||||
$ref: '#/components/schemas/RuletypesListSort'
|
||||
states:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
version:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
type: object
|
||||
RuletypesScheduleType:
|
||||
enum:
|
||||
- hourly
|
||||
@@ -9427,8 +9568,6 @@ components:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -9441,7 +9580,6 @@ components:
|
||||
- fieldContext
|
||||
- config
|
||||
- enabled
|
||||
- origin
|
||||
type: object
|
||||
SpantypesSpanMapperConfig:
|
||||
properties:
|
||||
@@ -9470,75 +9608,48 @@ components:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
version:
|
||||
type: integer
|
||||
required:
|
||||
- id
|
||||
- orgId
|
||||
- name
|
||||
- condition
|
||||
- enabled
|
||||
- origin
|
||||
- version
|
||||
type: object
|
||||
SpantypesSpanMapperGroupCondition:
|
||||
nullable: true
|
||||
properties:
|
||||
attributes:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
resource:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperGroupConditionKey'
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- attributes
|
||||
- resource
|
||||
type: object
|
||||
SpantypesSpanMapperGroupConditionKey:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
value:
|
||||
type: string
|
||||
required:
|
||||
- value
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperOperation:
|
||||
enum:
|
||||
- move
|
||||
- copy
|
||||
type: string
|
||||
SpantypesSpanMapperOrigin:
|
||||
enum:
|
||||
- user
|
||||
- system
|
||||
type: string
|
||||
SpantypesSpanMapperSource:
|
||||
properties:
|
||||
context:
|
||||
$ref: '#/components/schemas/SpantypesFieldContext'
|
||||
enabled:
|
||||
type: boolean
|
||||
key:
|
||||
type: string
|
||||
operation:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOperation'
|
||||
origin:
|
||||
$ref: '#/components/schemas/SpantypesSpanMapperOrigin'
|
||||
priority:
|
||||
type: integer
|
||||
required:
|
||||
@@ -9546,7 +9657,6 @@ components:
|
||||
- context
|
||||
- operation
|
||||
- priority
|
||||
- enabled
|
||||
type: object
|
||||
SpantypesSpanMapperTestSpan:
|
||||
properties:
|
||||
@@ -20345,11 +20455,237 @@ paths:
|
||||
summary: Get users by role id
|
||||
tags:
|
||||
- users
|
||||
/api/v2/rules:
|
||||
/api/v2/rule_views:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint lists all alert rules with their current evaluation
|
||||
state
|
||||
description: Returns every saved view in the calling user's org. Saved views
|
||||
are shared org-wide.
|
||||
operationId: ListRuleViews
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesListableRuleViews'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List rule saved views
|
||||
tags:
|
||||
- rules
|
||||
post:
|
||||
deprecated: false
|
||||
description: Persists the calling user's rule listing state (query, states,
|
||||
sort, order) as a named, reusable view shared across the org.
|
||||
operationId: CreateRuleView
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRuleView'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRuleView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Create rule saved view
|
||||
tags:
|
||||
- rules
|
||||
/api/v2/rule_views/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Removes a saved view. Saved views are shared org-wide. Deleting
|
||||
a non-existent view returns 404.
|
||||
operationId: DeleteRuleView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Delete rule saved view
|
||||
tags:
|
||||
- rules
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
operationId: UpdateRuleView
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesPostableRuleView'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesRuleView'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Update rule saved view
|
||||
tags:
|
||||
- rules
|
||||
/api/v2/rules:
|
||||
get:
|
||||
deprecated: true
|
||||
description: 'This endpoint lists all alert rules with their current evaluation
|
||||
state. Deprecated: use ListRulesV3, which supports filtering, sorting and
|
||||
pagination.'
|
||||
operationId: ListRules
|
||||
responses:
|
||||
"200":
|
||||
@@ -26131,6 +26467,90 @@ paths:
|
||||
summary: Get metric dashboards (v2)
|
||||
tags:
|
||||
- metrics
|
||||
/api/v3/rules:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a page of alert rules with their current evaluation state,
|
||||
trimmed to the fields the list page renders. Supports a filter DSL (`query`),
|
||||
a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`),
|
||||
order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). The
|
||||
response also carries the org's label pairs and the reserved filter keys for
|
||||
building filter suggestions.
|
||||
operationId: ListRulesV3
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: states
|
||||
schema:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesListSort'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/RuletypesListOrder'
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/RuletypesListableRules'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List alert rules (v3)
|
||||
tags:
|
||||
- rules
|
||||
/api/v3/traces/{traceID}/flamegraph:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -191,7 +191,7 @@ A standalone service only has the `factory.Service` lifecycle i.e it does not se
|
||||
// ... dependencies ...
|
||||
) user.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user"),
|
||||
// ... dependencies ...
|
||||
stopC: make(chan struct{}),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,6 +23,15 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
return append(f.TextToJsonColumn(column), ops...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
|
||||
sql := f.TextToJsonColumn(column)
|
||||
sql = append(sql, "->"...)
|
||||
sql = schema.Append(f.bunf, sql, mapField)
|
||||
sql = append(sql, "->>"...)
|
||||
sql = schema.Append(f.bunf, sql, key)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_typeof("...)
|
||||
|
||||
@@ -55,6 +55,39 @@ func TestJSONExtractString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONExtractMapValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
mapField string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain key",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "team",
|
||||
expected: `"data"::jsonb->'labels'->>'team'`,
|
||||
},
|
||||
{
|
||||
name: "dotted key stays one map entry",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "k8s.cluster",
|
||||
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -19,7 +19,9 @@ import type {
|
||||
|
||||
import type {
|
||||
CreateRule201,
|
||||
CreateRuleView201,
|
||||
DeleteRuleByIDPathParameters,
|
||||
DeleteRuleViewPathParameters,
|
||||
GetRuleByID200,
|
||||
GetRuleByIDPathParameters,
|
||||
GetRuleHistoryFilterKeys200,
|
||||
@@ -40,20 +42,372 @@ import type {
|
||||
GetRuleHistoryTopContributors200,
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRuleViews200,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
PatchRuleByID200,
|
||||
PatchRuleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
RuletypesPostableRuleViewDTO,
|
||||
TestRule200,
|
||||
UpdateRuleByIDPathParameters,
|
||||
UpdateRuleView200,
|
||||
UpdateRuleViewPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state
|
||||
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
export const listRuleViews = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListRuleViews200>({
|
||||
url: `/api/v2/rule_views`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRuleViewsQueryKey = () => {
|
||||
return [`/api/v2/rule_views`] as const;
|
||||
};
|
||||
|
||||
export const getListRuleViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
|
||||
signal,
|
||||
}) => listRuleViews(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRuleViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRuleViews>>
|
||||
>;
|
||||
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
|
||||
export function useListRuleViews<
|
||||
TData = Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRuleViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRuleViewsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List rule saved views
|
||||
*/
|
||||
export const invalidateListRuleViews = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRuleViewsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
|
||||
* @summary Create rule saved view
|
||||
*/
|
||||
export const createRuleView = (
|
||||
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateRuleView201>({
|
||||
url: `/api/v2/rule_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createRuleView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createRuleView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createRuleView>>
|
||||
>;
|
||||
export type CreateRuleViewMutationBody =
|
||||
| BodyType<RuletypesPostableRuleViewDTO>
|
||||
| undefined;
|
||||
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create rule saved view
|
||||
*/
|
||||
export const useCreateRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createRuleView>>,
|
||||
TError,
|
||||
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
|
||||
* @summary Delete rule saved view
|
||||
*/
|
||||
export const deleteRuleView = (
|
||||
{ id }: DeleteRuleViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/rule_views/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteRuleView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
{ pathParams: DeleteRuleViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteRuleView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>
|
||||
>;
|
||||
|
||||
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete rule saved view
|
||||
*/
|
||||
export const useDeleteRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteRuleView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteRuleViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Replaces a saved view's name and data. Saved views are shared org-wide.
|
||||
* @summary Update rule saved view
|
||||
*/
|
||||
export const updateRuleView = (
|
||||
{ id }: UpdateRuleViewPathParameters,
|
||||
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateRuleView200>({
|
||||
url: `/api/v2/rule_views/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: ruletypesPostableRuleViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateRuleViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateRuleView'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateRuleView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateRuleViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateRuleView>>
|
||||
>;
|
||||
export type UpdateRuleViewMutationBody =
|
||||
| BodyType<RuletypesPostableRuleViewDTO>
|
||||
| undefined;
|
||||
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update rule saved view
|
||||
*/
|
||||
export const useUpdateRuleView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateRuleView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateRuleViewPathParameters;
|
||||
data?: BodyType<RuletypesPostableRuleViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateRuleViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const listRules = (signal?: AbortSignal) => {
|
||||
@@ -95,6 +449,7 @@ export type ListRulesQueryResult = NonNullable<
|
||||
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
|
||||
@@ -114,6 +469,7 @@ export function useListRules<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const invalidateListRules = async (
|
||||
@@ -1368,3 +1724,97 @@ export const useTestRule = <
|
||||
> => {
|
||||
return useMutation(getTestRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const listRulesV3 = (
|
||||
params?: ListRulesV3Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: `/api/v3/rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryKey = (params?: ListRulesV3Params) => {
|
||||
return [`/api/v3/rules`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRulesV3QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRulesV3>>> = ({
|
||||
signal,
|
||||
}) => listRulesV3(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRulesV3QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRulesV3>>
|
||||
>;
|
||||
export type ListRulesV3QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
|
||||
export function useListRulesV3<
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRulesV3QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const invalidateListRulesV3 = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListRulesV3Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRulesV3QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -9896,6 +9896,149 @@ export interface RuletypesGettableTestRuleDTO {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesLabelPairDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum RuletypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum RuletypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
state = 'state',
|
||||
severity = 'severity',
|
||||
}
|
||||
export type RuletypesListableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesListableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesListableRuleDTOLabels;
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
state: RuletypesAlertStateDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesRuleViewDataDTO {
|
||||
order?: RuletypesListOrderDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
query?: string;
|
||||
sort?: RuletypesListSortDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
states?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RuletypesRuleViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
data: RuletypesRuleViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesListableRuleViewsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
views: RuletypesRuleViewDTO[];
|
||||
}
|
||||
|
||||
export interface RuletypesListableRulesDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels: RuletypesLabelPairDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -9992,11 +10135,6 @@ export interface RuletypesRuleConditionDTO {
|
||||
thresholds?: RuletypesRuleThresholdDataDTO;
|
||||
}
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesPostableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -10049,6 +10187,14 @@ export interface RuletypesPostableRuleDTO {
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesPostableRuleViewDTO {
|
||||
data: RuletypesRuleViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
|
||||
|
||||
export type RuletypesRuleDTOLabels = { [key: string]: string };
|
||||
@@ -10518,31 +10664,15 @@ export interface SpantypesGettableFlamegraphTraceDTO {
|
||||
startTimestampMillis: number;
|
||||
}
|
||||
|
||||
export enum SpantypesSpanMapperOriginDTO {
|
||||
user = 'user',
|
||||
system = 'system',
|
||||
}
|
||||
export interface SpantypesSpanMapperGroupConditionKeyDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type SpantypesSpanMapperGroupConditionDTOAnyOf = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
attributes: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
attributes: string[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
resource: SpantypesSpanMapperGroupConditionKeyDTO[] | null;
|
||||
resource: string[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -10578,7 +10708,6 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -10588,10 +10717,6 @@ export interface SpantypesSpanMapperGroupDTO {
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableSpanMapperGroupsDTO {
|
||||
@@ -10649,16 +10774,11 @@ export enum SpantypesSpanMapperOperationDTO {
|
||||
}
|
||||
export interface SpantypesSpanMapperSourceDTO {
|
||||
context: SpantypesFieldContextDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
origin?: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -10700,7 +10820,6 @@ export interface SpantypesSpanMapperDTO {
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
origin: SpantypesSpanMapperOriginDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -13296,6 +13415,36 @@ export type GetUsersByRoleID200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRuleViews200 = {
|
||||
data: RuletypesListableRuleViewsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateRuleView201 = {
|
||||
data: RuletypesRuleViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteRuleViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRuleViewPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateRuleView200 = {
|
||||
data: RuletypesRuleViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRules200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -13857,6 +14006,45 @@ export type GetMetricDashboardsV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRulesV3Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @description undefined
|
||||
*/
|
||||
states?: string[];
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: RuletypesListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: RuletypesListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListRulesV3200 = {
|
||||
data: RuletypesListableRulesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -614,18 +614,6 @@ export const listViewInitialLogQuery: Query = {
|
||||
},
|
||||
};
|
||||
|
||||
export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
|
||||
[PANEL_TYPES.TIME_SERIES]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.VALUE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.TABLE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.LIST]: listViewInitialLogQuery,
|
||||
[PANEL_TYPES.TRACE]: initialQueriesMap.traces,
|
||||
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
|
||||
};
|
||||
|
||||
export const listViewInitialTraceQuery: Query = {
|
||||
// it should be the above commented query
|
||||
...initialQueriesMap.traces,
|
||||
|
||||
@@ -333,7 +333,6 @@ describe('AttributeMappingsTab (integration)', () => {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority,
|
||||
enabled: true,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ConditionKey } from 'container/LLMObservability/AttributeMapping/types';
|
||||
|
||||
import styles from './ConditionsTooltip.module.scss';
|
||||
|
||||
interface ConditionsTooltipProps {
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
}
|
||||
|
||||
function ConditionsTooltip({
|
||||
@@ -35,8 +33,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{attributes.map((key) => (
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
@@ -49,8 +47,8 @@ function ConditionsTooltip({
|
||||
</Typography.Text>
|
||||
<div className={styles.keyList}>
|
||||
{resource.map((key) => (
|
||||
<code key={`${key.origin}-${key.value}`} className={styles.key}>
|
||||
{key.value}
|
||||
<code key={key} className={styles.key}>
|
||||
{key}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
SpantypesSpanMapperDTO as Mapper,
|
||||
SpantypesSpanMapperGroupDTO as MapperGroup,
|
||||
SpantypesSpanMapperOperationDTO as MapperOperation,
|
||||
SpantypesSpanMapperOriginDTO as MapperOrigin,
|
||||
SpantypesSpanMapperTestSpanDTO as TestSpan,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
@@ -22,15 +21,9 @@ export function makeGroup(overrides: Partial<MapperGroup> = {}): MapperGroup {
|
||||
orgId: 'org-1',
|
||||
name: 'demo',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
version: 0,
|
||||
condition: {
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
@@ -42,7 +35,6 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
groupId: 'group-1',
|
||||
name: 'gen_ai.request.model',
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
fieldContext: FieldContext.attribute,
|
||||
config: {
|
||||
sources: [
|
||||
@@ -51,16 +43,12 @@ export function makeMapper(overrides: Partial<Mapper> = {}): Mapper {
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
{
|
||||
key: 'llm.model',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.move,
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -97,12 +85,8 @@ export const mockGroups: MapperGroup[] = [
|
||||
id: 'group-1',
|
||||
name: 'demo',
|
||||
condition: {
|
||||
attributes: [
|
||||
{ value: 'ai.embeddings', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
resource: [
|
||||
{ value: 'cloud.account.id', enabled: true, origin: MapperOrigin.user },
|
||||
],
|
||||
attributes: ['ai.embeddings'],
|
||||
resource: ['cloud.account.id'],
|
||||
},
|
||||
}),
|
||||
makeGroup({
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
|
||||
import {
|
||||
ConditionKey,
|
||||
FieldContextValue,
|
||||
} from 'container/LLMObservability/AttributeMapping/types';
|
||||
import { createConditionKey } from 'container/LLMObservability/AttributeMapping/utils';
|
||||
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
|
||||
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
|
||||
import styles from './ConditionKeyList.module.scss';
|
||||
|
||||
interface ConditionKeyListProps {
|
||||
label: string;
|
||||
labelHint?: string;
|
||||
keys: ConditionKey[];
|
||||
keys: string[];
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
testIdPrefix: string;
|
||||
fieldContext: FieldContextValue;
|
||||
onChange: (keys: ConditionKey[]) => void;
|
||||
onChange: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
function ConditionKeyList({
|
||||
@@ -31,11 +27,11 @@ function ConditionKeyList({
|
||||
onChange,
|
||||
}: ConditionKeyListProps): JSX.Element {
|
||||
const updateKey = (index: number, value: string): void => {
|
||||
onChange(keys.map((key, i) => (i === index ? { ...key, value } : key)));
|
||||
onChange(keys.map((key, i) => (i === index ? value : key)));
|
||||
};
|
||||
|
||||
const addKey = (): void => {
|
||||
onChange([...keys, createConditionKey()]);
|
||||
onChange([...keys, '']);
|
||||
};
|
||||
|
||||
const removeKey = (index: number): void => {
|
||||
@@ -57,7 +53,7 @@ function ConditionKeyList({
|
||||
<KeySearchInput
|
||||
className={styles.keyInput}
|
||||
placeholder={placeholder}
|
||||
value={key.value}
|
||||
value={key}
|
||||
fieldContext={fieldContext}
|
||||
onChange={(next): void => updateKey(index, next)}
|
||||
testId={`${testIdPrefix}-${index}`}
|
||||
|
||||
@@ -42,9 +42,7 @@ function sourcesEqual(a: SourceConfig[], b: SourceConfig[]): boolean {
|
||||
(source, index) =>
|
||||
source.key === b[index].key &&
|
||||
source.context === b[index].context &&
|
||||
source.operation === b[index].operation &&
|
||||
source.enabled === b[index].enabled &&
|
||||
source.origin === b[index].origin,
|
||||
source.operation === b[index].operation,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
SpantypesSpanMapperDTO,
|
||||
SpantypesSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperOperationDTO,
|
||||
SpantypesSpanMapperOriginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type MapperGroup = SpantypesSpanMapperGroupDTO;
|
||||
@@ -12,16 +11,6 @@ export const FieldContext = SpantypesFieldContextDTO;
|
||||
export type FieldContextValue = SpantypesFieldContextDTO;
|
||||
export const MapperOperation = SpantypesSpanMapperOperationDTO;
|
||||
export type MapperOperationValue = SpantypesSpanMapperOperationDTO;
|
||||
export const MapperOrigin = SpantypesSpanMapperOriginDTO;
|
||||
export type MapperOriginValue = SpantypesSpanMapperOriginDTO;
|
||||
|
||||
// One condition substring. Shipped (system) keys are read-only apart from
|
||||
// `enabled`; user keys are fully editable.
|
||||
export interface ConditionKey {
|
||||
value: string;
|
||||
enabled: boolean;
|
||||
origin: MapperOriginValue;
|
||||
}
|
||||
|
||||
export type MapperDraftMode = 'add' | 'edit';
|
||||
|
||||
@@ -29,8 +18,6 @@ export interface SourceConfig {
|
||||
key: string;
|
||||
context: SpantypesFieldContextDTO;
|
||||
operation: SpantypesSpanMapperOperationDTO;
|
||||
enabled: boolean;
|
||||
origin: MapperOriginValue;
|
||||
}
|
||||
|
||||
// Editable form state for a mapper. `sources` is ordered highest priority
|
||||
@@ -46,8 +33,8 @@ export interface MapperDraft {
|
||||
export interface GroupDraft {
|
||||
id: string | null;
|
||||
name: string;
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -64,8 +51,8 @@ export interface DraftGroup {
|
||||
localId: string;
|
||||
serverId: string | null;
|
||||
name: string;
|
||||
attributes: ConditionKey[];
|
||||
resource: ConditionKey[];
|
||||
attributes: string[];
|
||||
resource: string[];
|
||||
enabled: boolean;
|
||||
mappers: DraftMapper[];
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
SpantypesPostableSpanMapperDTO,
|
||||
SpantypesPostableSpanMapperGroupDTO,
|
||||
SpantypesSpanMapperGroupConditionKeyDTO,
|
||||
SpantypesUpdatableSpanMapperDTO,
|
||||
SpantypesUpdatableSpanMapperGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
ConditionKey,
|
||||
DraftGroup,
|
||||
DraftMapper,
|
||||
FieldContext,
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
MapperDraft,
|
||||
MapperGroup,
|
||||
MapperOperation,
|
||||
MapperOrigin,
|
||||
SourceConfig,
|
||||
} from './types';
|
||||
|
||||
@@ -27,36 +24,20 @@ function genLocalId(prefix: 'group' | 'mapper'): string {
|
||||
return `local-${prefix}-${uuid()}`;
|
||||
}
|
||||
|
||||
export function createConditionKey(value = ''): ConditionKey {
|
||||
return { value, enabled: true, origin: MapperOrigin.user };
|
||||
}
|
||||
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order. A shipped and
|
||||
// a user key may share a value, so the origin is part of the identity.
|
||||
function cleanKeys(keys: ConditionKey[]): ConditionKey[] {
|
||||
// Trimmed, de-duplicated, non-empty keys preserving input order.
|
||||
function cleanKeys(keys: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ConditionKey[] = [];
|
||||
const result: string[] = [];
|
||||
keys.forEach((raw) => {
|
||||
const value = raw.value.trim();
|
||||
const dedupeKey = `${raw.origin}:${value}`;
|
||||
if (value && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...raw, value });
|
||||
const key = raw.trim();
|
||||
if (key && !seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fromConditionKeys(
|
||||
keys: SpantypesSpanMapperGroupConditionKeyDTO[] | null | undefined,
|
||||
): ConditionKey[] {
|
||||
return (keys ?? []).map((key) => ({
|
||||
value: key.value,
|
||||
enabled: key.enabled,
|
||||
origin: key.origin ?? MapperOrigin.user,
|
||||
}));
|
||||
}
|
||||
|
||||
// Source configs for a mapper, highest priority first (first match wins at
|
||||
// evaluation time).
|
||||
function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
@@ -67,8 +48,6 @@ function getMapperSources(mapper: Mapper): SourceConfig[] {
|
||||
key: source.key,
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
enabled: source.enabled,
|
||||
origin: source.origin ?? MapperOrigin.user,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -77,8 +56,6 @@ export function createEmptySource(): SourceConfig {
|
||||
key: '',
|
||||
context: FieldContext.attribute,
|
||||
operation: MapperOperation.copy,
|
||||
enabled: true,
|
||||
origin: MapperOrigin.user,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,7 +72,7 @@ function getCleanSources(draft: MapperDraft): SourceConfig[] {
|
||||
const result: SourceConfig[] = [];
|
||||
draft.sources.forEach((source) => {
|
||||
const key = source.key.trim();
|
||||
const dedupeKey = `${source.origin}:${source.context}:${key}`;
|
||||
const dedupeKey = `${source.context}:${key}`;
|
||||
if (key && !seen.has(dedupeKey)) {
|
||||
seen.add(dedupeKey);
|
||||
result.push({ ...source, key });
|
||||
@@ -118,8 +95,6 @@ function buildSources(
|
||||
context: source.context,
|
||||
operation: source.operation,
|
||||
priority: sources.length - index,
|
||||
enabled: source.enabled,
|
||||
origin: source.origin,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -148,7 +123,7 @@ export function buildUpdatableMapper(
|
||||
export const EMPTY_GROUP_DRAFT: GroupDraft = {
|
||||
id: null,
|
||||
name: '',
|
||||
attributes: [createConditionKey()],
|
||||
attributes: [''],
|
||||
resource: [],
|
||||
enabled: true,
|
||||
};
|
||||
@@ -195,8 +170,8 @@ export function buildDraftGroup(
|
||||
localId: group.id,
|
||||
serverId: group.id,
|
||||
name: group.name,
|
||||
attributes: fromConditionKeys(group.condition?.attributes),
|
||||
resource: fromConditionKeys(group.condition?.resource),
|
||||
attributes: group.condition?.attributes ?? [],
|
||||
resource: group.condition?.resource ?? [],
|
||||
enabled: group.enabled,
|
||||
mappers: mappers.map(buildDraftMapper),
|
||||
};
|
||||
@@ -207,8 +182,7 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
|
||||
return {
|
||||
id: group.localId,
|
||||
name: group.name,
|
||||
attributes:
|
||||
group.attributes.length > 0 ? group.attributes : [createConditionKey()],
|
||||
attributes: group.attributes.length > 0 ? group.attributes : [''],
|
||||
resource: group.resource,
|
||||
enabled: group.enabled,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.optionsTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
export function BlockLink({
|
||||
children,
|
||||
to,
|
||||
openInNewTab,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
to: string;
|
||||
openInNewTab: boolean;
|
||||
}): any {
|
||||
// Display block to make the whole cell clickable
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
style={{ display: 'block' }}
|
||||
target={openInNewTab ? '_blank' : '_self'}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const transformDataWithDate = (
|
||||
data: QueryDataV3[],
|
||||
): Omit<ILog, 'timestamp'>[] =>
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const traceId = readId(record.traceID) || readId(record.trace_id);
|
||||
const spanId = readId(record.spanID) || readId(record.span_id);
|
||||
|
||||
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
) => string | number,
|
||||
): ColumnsType<RowData> => {
|
||||
const initialColumns: ColumnsType<RowData> = [
|
||||
{
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
title: 'Timestamp',
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
const date =
|
||||
typeof value === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
value,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
value / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography.Text>{date}</Typography.Text>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<RowData> =
|
||||
selectedColumns.map((props) => {
|
||||
const name = props?.name || (props as any)?.key;
|
||||
const fieldContext = props?.fieldContext || (props as any)?.type;
|
||||
return {
|
||||
title: name,
|
||||
dataIndex: name,
|
||||
key: buildCompositeKey(name, fieldContext),
|
||||
width: 145,
|
||||
render: (value, item): JSX.Element => {
|
||||
if (value === '') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>N/A</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'httpMethod' ||
|
||||
name === 'responseStatusCode' ||
|
||||
name === 'response_status_code' ||
|
||||
name === 'http_method'
|
||||
) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{value}
|
||||
</Badge>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'durationNano' || name === 'duration_nano') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>{getMs(value)}ms</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
|
||||
<Typography data-testid={name}>
|
||||
<LineClampedText text={value} lines={3} />
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
responsive: ['md'],
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
|
||||
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
|
||||
// positional ids; `timestamp` is lifted from the wrapping ListItem.
|
||||
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
|
||||
const list = data[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => {
|
||||
const row = item.data as Record<string, unknown>;
|
||||
return {
|
||||
...row,
|
||||
timestamp: item.timestamp,
|
||||
id: row.span_id,
|
||||
};
|
||||
}) as TracesTableRow[];
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
.loading-traces {
|
||||
padding: 24px 0;
|
||||
height: 240px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
.loading-traces-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
.loading-gif {
|
||||
height: 72px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import './TraceLoading.styles.scss';
|
||||
|
||||
export function TracesLoading(): JSX.Element {
|
||||
const { t } = useTranslation('common');
|
||||
return (
|
||||
<div className="loading-traces">
|
||||
<div className="loading-traces-content">
|
||||
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
|
||||
|
||||
<Typography>
|
||||
{t('pending_data_placeholder', { dataSource: DataSource.TRACES })}
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import {
|
||||
DURATION_FIELD_NAMES,
|
||||
STATUS_FIELD_NAMES,
|
||||
TIMESTAMP_FIELD_NAMES,
|
||||
TRACE_ID_FIELD_NAMES,
|
||||
} from './constants';
|
||||
import { stringifyCellValue } from './utils';
|
||||
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (TIMESTAMP_FIELD_NAMES.has(name)) {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
const text = String(formatted);
|
||||
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
const text = stringifyCellValue(value);
|
||||
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (STATUS_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (DURATION_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name} title={text}>
|
||||
{text}
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldCell;
|
||||
@@ -0,0 +1,26 @@
|
||||
.tableWrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tracesTable {
|
||||
--tanstack-table-row-height: 54px;
|
||||
--tanstack-table-header-height: 54px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-header-padding-left-override: 5px;
|
||||
|
||||
--tanstack-cell-header-padding-left-first-column: 15px;
|
||||
|
||||
--tanstack-plain-body-line-clamp: 1;
|
||||
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-hover-bg: var(--l1-background-hover);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
} from 'components/TanStackTableView/types';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { TracesTableRow } from './getFieldColumn';
|
||||
import styles from './TracesTable.module.scss';
|
||||
|
||||
export type TracesTableProps = {
|
||||
data: TracesTableRow[];
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
columnStorageKey?: string;
|
||||
respectColumnOrder?: boolean;
|
||||
panelType: PanelTypeKeys;
|
||||
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
|
||||
getRowHref: (row: TracesTableRow) => string;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | Error | null;
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
function TracesTable({
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
respectColumnOrder = false,
|
||||
panelType,
|
||||
getRowHref,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
},
|
||||
[history, getRowHref],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
|
||||
},
|
||||
[getRowHref],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{!isError && data.length !== 0 && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TracesTableRow>
|
||||
data={data}
|
||||
columns={columns}
|
||||
className={styles.tracesTable}
|
||||
columnStorageKey={columnStorageKey}
|
||||
respectColumnOrder={respectColumnOrder}
|
||||
isLoading={isFetching}
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TracesTable.defaultProps = {
|
||||
columnStorageKey: undefined,
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
export default TracesTable;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'http.method',
|
||||
'http.request.method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
'http.status_code',
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
|
||||
export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext, fieldDataType } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext, fieldDataType),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row[name],
|
||||
enableMove: !isTimestamp,
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
235
frontend/src/container/LLMObservability/Explorer/aiActions.ts
Normal file
235
frontend/src/container/LLMObservability/Explorer/aiActions.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* AI Assistant page-action factories for the Traces Explorer.
|
||||
*
|
||||
* Mirrors the logs equivalents — each factory closes over live page
|
||||
* state/callbacks so `execute()` always operates on the current query, and
|
||||
* the page component instantiates them via `useMemo` + `usePageActions`.
|
||||
*
|
||||
* See `pages/LogsExplorer/aiActions.ts` for the rationale behind writing
|
||||
* BOTH `filters.items` and `filter.expression` and then re-using the same
|
||||
* URL parser shape via `redirectWithQueryBuilderData`.
|
||||
*/
|
||||
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
aiFilterToTagFilterItem,
|
||||
FILTER_OP_ENUM,
|
||||
FILTER_VALUE_DESCRIPTION,
|
||||
FilterDeps,
|
||||
replaceFirstQueryData,
|
||||
} from 'container/AIAssistant/pageActions/builderQueryHelpers';
|
||||
import {
|
||||
ActionResult,
|
||||
PageAction,
|
||||
} from 'container/AIAssistant/pageActions/types';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
interface AIFilter {
|
||||
key: string;
|
||||
op: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface RunQueryParams {
|
||||
filters: AIFilter[];
|
||||
}
|
||||
|
||||
interface AddFilterParams {
|
||||
key: string;
|
||||
op: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type TracesView = 'list' | 'timeseries' | 'table' | 'trace';
|
||||
|
||||
interface ChangeViewParams {
|
||||
view: TracesView;
|
||||
}
|
||||
|
||||
interface SaveViewParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all active span filters and navigate to the updated query URL
|
||||
* (which makes the WHERE clause reflect the new filters and triggers a re-run).
|
||||
*/
|
||||
export function tracesRunQueryAction(
|
||||
deps: FilterDeps,
|
||||
): PageAction<RunQueryParams> {
|
||||
return {
|
||||
id: 'traces.runQuery',
|
||||
description: 'Replace the active trace filters and re-run the query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Replacement filter list',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Attribute key, e.g. service.name, http.status_code',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: [...FILTER_OP_ENUM],
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: FILTER_VALUE_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['filters'],
|
||||
},
|
||||
autoApply: true,
|
||||
execute: async ({ filters }): Promise<ActionResult> => {
|
||||
const baseQuery = deps.currentQuery.builder.queryData[0];
|
||||
if (!baseQuery) {
|
||||
throw new Error('No active query found in Traces Explorer.');
|
||||
}
|
||||
|
||||
const tagItems = filters.map(aiFilterToTagFilterItem);
|
||||
const newFilters = { items: tagItems, op: 'AND' };
|
||||
const updatedBuilderQuery: IBuilderQuery = {
|
||||
...baseQuery,
|
||||
filters: newFilters,
|
||||
filter: convertFiltersToExpression(newFilters),
|
||||
};
|
||||
|
||||
deps.handleSetQueryData(0, updatedBuilderQuery);
|
||||
deps.redirectWithQueryBuilderData(
|
||||
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
|
||||
);
|
||||
|
||||
return {
|
||||
summary: `Query updated with ${filters.length} filter(s) and re-run.`,
|
||||
};
|
||||
},
|
||||
getContext: (): Record<string, unknown> => ({
|
||||
filters:
|
||||
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
|
||||
(f: TagFilterItem) => ({
|
||||
key: f.key?.key,
|
||||
op: f.op,
|
||||
value: f.value,
|
||||
}),
|
||||
) ?? [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a single filter to the existing trace query and navigate to the
|
||||
* updated URL.
|
||||
*/
|
||||
export function tracesAddFilterAction(
|
||||
deps: FilterDeps,
|
||||
): PageAction<AddFilterParams> {
|
||||
return {
|
||||
id: 'traces.addFilter',
|
||||
description: 'Add a single filter to the current trace query and re-run',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: 'Attribute key, e.g. service.name, http.status_code',
|
||||
},
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: [...FILTER_OP_ENUM],
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
description: FILTER_VALUE_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
autoApply: true,
|
||||
execute: async ({ key, op, value }): Promise<ActionResult> => {
|
||||
const baseQuery = deps.currentQuery.builder.queryData[0];
|
||||
if (!baseQuery) {
|
||||
throw new Error('No active query found in Traces Explorer.');
|
||||
}
|
||||
|
||||
const existing = baseQuery.filters?.items ?? [];
|
||||
const newItem = aiFilterToTagFilterItem({ key, op, value });
|
||||
const newFilters = { items: [...existing, newItem], op: 'AND' };
|
||||
const updatedBuilderQuery: IBuilderQuery = {
|
||||
...baseQuery,
|
||||
filters: newFilters,
|
||||
filter: convertFiltersToExpression(newFilters),
|
||||
};
|
||||
|
||||
deps.handleSetQueryData(0, updatedBuilderQuery);
|
||||
deps.redirectWithQueryBuilderData(
|
||||
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
|
||||
);
|
||||
|
||||
return { summary: `Filter added: ${key} ${op} "${value}". Query re-run.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the traces explorer between list / timeseries / table / trace views.
|
||||
*/
|
||||
export function tracesChangeViewAction(deps: {
|
||||
onChangeView: (view: TracesView) => void;
|
||||
}): PageAction<ChangeViewParams> {
|
||||
return {
|
||||
id: 'traces.changeView',
|
||||
description:
|
||||
'Switch the Traces Explorer between list, timeseries, table, and trace views',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
view: {
|
||||
type: 'string',
|
||||
enum: ['list', 'timeseries', 'table', 'trace'],
|
||||
description: 'The panel view to switch to',
|
||||
},
|
||||
},
|
||||
required: ['view'],
|
||||
},
|
||||
execute: async ({ view }): Promise<ActionResult> => {
|
||||
deps.onChangeView(view);
|
||||
return { summary: `Switched to the "${view}" view.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current trace query as a named view (stub — wires to real API
|
||||
* when available).
|
||||
*/
|
||||
export function tracesSaveViewAction(deps: {
|
||||
onSaveView: (name: string) => Promise<void>;
|
||||
}): PageAction<SaveViewParams> {
|
||||
return {
|
||||
id: 'traces.saveView',
|
||||
description: 'Save the current trace query as a named view',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Name for the saved view' },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
execute: async ({ name }): Promise<ActionResult> => {
|
||||
await deps.onSaveView(name);
|
||||
return { summary: `View "${name}" saved.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Atom,
|
||||
Filter,
|
||||
SquareMousePointer,
|
||||
Terminal,
|
||||
Binoculars,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
|
||||
import './ToolbarActions.styles.scss';
|
||||
|
||||
interface LeftToolbarActionsProps {
|
||||
items: any;
|
||||
selectedView: string;
|
||||
onChangeSelectedView: (view: ExplorerViews) => void;
|
||||
showFilter: boolean;
|
||||
handleFilterVisibilityChange: () => void;
|
||||
}
|
||||
|
||||
const activeTab = 'active-tab';
|
||||
|
||||
export default function LeftToolbarActions({
|
||||
items,
|
||||
selectedView,
|
||||
onChangeSelectedView,
|
||||
showFilter,
|
||||
handleFilterVisibilityChange,
|
||||
}: LeftToolbarActionsProps): JSX.Element {
|
||||
const { clickhouse, list, timeseries, table, trace } = items;
|
||||
|
||||
return (
|
||||
<div className="left-toolbar">
|
||||
{!showFilter && (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size={12} />
|
||||
<ArrowUpToLine size={12} style={{ transform: 'rotate(90deg)' }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="left-toolbar-query-actions">
|
||||
{list?.show && (
|
||||
<Tooltip title="List View">
|
||||
<Button
|
||||
disabled={list.disabled}
|
||||
className={cx(
|
||||
'list-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === list.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(list.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="search-view" />
|
||||
List View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{trace?.show && (
|
||||
<Tooltip title="Trace View">
|
||||
<Button
|
||||
disabled={trace.disabled}
|
||||
className={cx(
|
||||
'trace-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === trace.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(trace.key)}
|
||||
>
|
||||
<SquareMousePointer size={14} data-testid="trace-view" />
|
||||
Trace View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{timeseries?.show && (
|
||||
<Tooltip title="Time Series">
|
||||
<Button
|
||||
disabled={timeseries.disabled}
|
||||
className={cx(
|
||||
'timeseries-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === timeseries.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(timeseries.key)}
|
||||
>
|
||||
<Atom size={14} data-testid="query-builder-view" />
|
||||
Time Series
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{clickhouse?.show && (
|
||||
<Tooltip title="Clickhouse">
|
||||
<Button
|
||||
disabled={clickhouse.disabled}
|
||||
className={cx(
|
||||
'clickhouse-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === clickhouse.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(clickhouse.key)}
|
||||
>
|
||||
<Terminal size={14} data-testid="clickhouse-view" />
|
||||
Clickhouse
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{table?.show && (
|
||||
<Tooltip title="Table">
|
||||
<Button
|
||||
disabled={table.disabled}
|
||||
className={cx(
|
||||
'table-view-tab',
|
||||
'explorer-view-option',
|
||||
selectedView === table.key ? activeTab : '',
|
||||
)}
|
||||
onClick={(): void => onChangeSelectedView(table.key)}
|
||||
>
|
||||
<Binoculars size={14} data-testid="query-builder-view-v2" />
|
||||
Table
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
.left-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
height: 32px;
|
||||
margin-right: 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.left-toolbar-query-actions {
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
flex-direction: row;
|
||||
border-bottom: none;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.prom-ql-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.explorer-view-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
border: none;
|
||||
padding: 9px;
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
gap: 8px;
|
||||
|
||||
&.active-tab {
|
||||
background-color: var(--primary-background);
|
||||
border-bottom: 1px solid var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--l3-background);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent !important;
|
||||
border-left: 1px solid transparent !important;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.frequency-chart-view-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-robin-600);
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
.loading-btn {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 33px;
|
||||
padding: 4px 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-run {
|
||||
display: flex;
|
||||
height: 33px;
|
||||
padding: 4px 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1 0 0;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
border: none;
|
||||
}
|
||||
.cancel-run:hover {
|
||||
background-color: var(--bg-cherry-400) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
@@ -426,6 +426,30 @@ describe('resolvePanelContextLinks', () => {
|
||||
|
||||
expect(resolved[0].url).toBe('https://wiki/{{_service.name}}');
|
||||
});
|
||||
|
||||
it('carries targetBlank through, defaulting to true when unset', () => {
|
||||
const resolved = resolvePanelContextLinks(
|
||||
[
|
||||
{ name: 'Same tab', url: 'https://wiki/a', targetBlank: false },
|
||||
{ name: 'New tab', url: 'https://wiki/b', targetBlank: true },
|
||||
{ name: 'Unset', url: 'https://wiki/c' },
|
||||
{
|
||||
name: 'Literal',
|
||||
url: 'https://wiki/d',
|
||||
targetBlank: false,
|
||||
renderVariables: false,
|
||||
},
|
||||
],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(resolved.map((link) => link.targetBlank)).toStrictEqual([
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepClickTimeRange', () => {
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface ResolvedDrilldownLink {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
/** Opens in a new tab; links saved before the toggle existed default to true. */
|
||||
targetBlank: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,14 +28,16 @@ export function resolvePanelContextLinks(
|
||||
return usable.map((link, index) => {
|
||||
const rawLabel = link.name || link.url || '';
|
||||
const rawUrl = link.url ?? '';
|
||||
const targetBlank = link.targetBlank ?? true;
|
||||
// Only an explicit `false` opts out; undefined defaults to substitution on.
|
||||
if (link.renderVariables === false) {
|
||||
return { id: String(index), label: rawLabel, url: rawUrl };
|
||||
return { id: String(index), label: rawLabel, url: rawUrl, targetBlank };
|
||||
}
|
||||
return {
|
||||
id: String(index),
|
||||
label: resolveTexts({ texts: [rawLabel], processedVariables }).fullTexts[0],
|
||||
url: resolveContextLinkUrl(rawUrl, processedVariables),
|
||||
targetBlank,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ function DrilldownAggregateMenu({
|
||||
void logEvent(DashboardDetailEvents.DrilldownAction, {
|
||||
action: 'contextLink',
|
||||
});
|
||||
openInNewTab(link.url);
|
||||
openInNewTab(link.url, !!link.targetBlank);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -767,10 +767,15 @@ export function QueryBuilderProvider({
|
||||
queryItem.dataSource
|
||||
].builder.queryData;
|
||||
|
||||
propsRequired?.push('dataSource');
|
||||
propsRequired?.forEach((p: any) => {
|
||||
set(queryItem, p, get(newQueryItem, p));
|
||||
});
|
||||
// `dataSource` travels with the panel type's fields, but is appended to a
|
||||
// copy: `propsRequired` is the list held in
|
||||
// `panelTypeDataSourceFormValuesMap`, and pushing onto it grew that
|
||||
// module-level array by one entry on every call.
|
||||
if (propsRequired) {
|
||||
[...propsRequired, 'dataSource'].forEach((p: any) => {
|
||||
set(queryItem, p, get(newQueryItem, p));
|
||||
});
|
||||
}
|
||||
return queryItem;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,13 +211,11 @@ export enum QueryFunctionsTypes {
|
||||
FILL_ZERO = 'fillZero',
|
||||
}
|
||||
|
||||
export type PanelTypeKeys =
|
||||
| 'TIME_SERIES'
|
||||
| 'VALUE'
|
||||
| 'TABLE'
|
||||
| 'LIST'
|
||||
| 'TRACE'
|
||||
| 'EMPTY_WIDGET';
|
||||
/**
|
||||
* Key names of {@link PANEL_TYPES}. Derived rather than listed: the hand-written
|
||||
* version had fallen behind the enum by three members (`BAR`, `PIE`, `HISTOGRAM`).
|
||||
*/
|
||||
export type PanelTypeKeys = keyof typeof PANEL_TYPES;
|
||||
|
||||
export enum ReduceOperators {
|
||||
LAST = 'last',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
export const openInNewTab = (path: string): void => {
|
||||
window.open(withBasePath(path), '_blank');
|
||||
export const openInNewTab = (path: string, newTab = true): void => {
|
||||
if (newTab) {
|
||||
window.open(withBasePath(path), '_blank');
|
||||
} else {
|
||||
window.location.assign(withBasePath(path));
|
||||
}
|
||||
};
|
||||
|
||||
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,13 @@ func newConfig() factory.Config {
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if err := c.Config.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.Address == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "apiserver.address is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,11 +8,18 @@ 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_TLS_ENABLED", "true")
|
||||
t.Setenv("SIGNOZ_APISERVER_TLS_CERT__FILE", "/etc/signoz/server.crt")
|
||||
t.Setenv("SIGNOZ_APISERVER_TLS_KEY__FILE", "/etc/signoz/server.key")
|
||||
t.Setenv("SIGNOZ_APISERVER_TLS_MIN__VERSION", "1.3")
|
||||
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 +45,16 @@ func TestNewWithEnvProvider(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := &Config{
|
||||
Config: httpserver.Config{
|
||||
Address: "0.0.0.0:9090",
|
||||
ReadTimeout: 80 * time.Second,
|
||||
TLS: httpserver.TLS{
|
||||
Enabled: true,
|
||||
CertFile: "/etc/signoz/server.crt",
|
||||
KeyFile: "/etc/signoz/server.key",
|
||||
MinVersion: "1.3",
|
||||
},
|
||||
},
|
||||
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,18 +41,22 @@ 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
|
||||
@@ -132,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] {
|
||||
@@ -179,6 +192,11 @@ func NewFactory(
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
globalConfig,
|
||||
identNResolver,
|
||||
sharder,
|
||||
auditor,
|
||||
web,
|
||||
quickFilterModule,
|
||||
quickFilterHandler,
|
||||
)
|
||||
@@ -228,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) {
|
||||
@@ -235,9 +258,10 @@ 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,
|
||||
authzService: authzService,
|
||||
@@ -282,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
|
||||
}
|
||||
|
||||
@@ -15,10 +15,26 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
ID: "ListRules",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
|
||||
Response: make([]*ruletypes.Rule, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules (v3)",
|
||||
Description: "Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.",
|
||||
RequestQuery: new(ruletypes.ListRulesParams),
|
||||
Response: new(ruletypes.ListableRules),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
@@ -116,6 +132,64 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRuleViews), handler.OpenAPIDef{
|
||||
ID: "ListRuleViews",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List rule saved views",
|
||||
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide.",
|
||||
Response: new(ruletypes.ListableRuleViews),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.CreateRuleView), handler.OpenAPIDef{
|
||||
ID: "CreateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Create rule saved view",
|
||||
Description: "Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.",
|
||||
Request: new(ruletypes.PostableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.RuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.UpdateRuleView), handler.OpenAPIDef{
|
||||
ID: "UpdateRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Update rule saved view",
|
||||
Description: "Replaces a saved view's name and data. Saved views are shared org-wide.",
|
||||
Request: new(ruletypes.UpdatableRuleView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.RuleView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rule_views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.DeleteRuleView), handler.OpenAPIDef{
|
||||
ID: "DeleteRuleView",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Delete rule saved view",
|
||||
Description: "Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.",
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
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,89 @@
|
||||
package server
|
||||
|
||||
// Config holds the configuration for http.
|
||||
import (
|
||||
"crypto/tls"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var tlsVersions = map[string]uint16{
|
||||
"1.2": tls.VersionTLS12,
|
||||
"1.3": tls.VersionTLS13,
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
//Address specifies the TCP address for the server to listen on, in the form "host:port".
|
||||
// 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"`
|
||||
|
||||
TLS TLS `mapstructure:"tls"`
|
||||
}
|
||||
|
||||
type TLS struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
|
||||
// The full path to the certificate file.
|
||||
CertFile string `mapstructure:"cert_file"`
|
||||
|
||||
// The full path to the key file.
|
||||
KeyFile string `mapstructure:"key_file"`
|
||||
|
||||
// MinVersion is the minimum acceptable TLS version, "1.2" or "1.3". Empty uses the Go default.
|
||||
MinVersion string `mapstructure:"min_version"`
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if !c.TLS.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if c.TLS.CertFile == "" || c.TLS.KeyFile == "" {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "tls::cert_file and tls::key_file are required when tls is enabled")
|
||||
}
|
||||
|
||||
_, err := tlsVersion(c.TLS.MinVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tlsConfig TLS) Config() (*tls.Config, error) {
|
||||
cert, err := tls.LoadX509KeyPair(tlsConfig.CertFile, tlsConfig.KeyFile)
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot load tls::cert_file and tls::key_file: %v", err)
|
||||
}
|
||||
|
||||
minVersion, err := tlsVersion(tlsConfig.MinVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: minVersion,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tlsVersion(name string) (uint16, error) {
|
||||
if name == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
version, ok := tlsVersions[name]
|
||||
if !ok {
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid tls version %q, must be \"1.2\" or \"1.3\"", name)
|
||||
}
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
@@ -28,17 +28,30 @@ func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error)
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot build http server, logger is required")
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
if cfg.TLS.Enabled {
|
||||
tlsConfig, err := cfg.TLS.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv.TLSConfig = tlsConfig
|
||||
}
|
||||
|
||||
return &Server{
|
||||
srv: srv,
|
||||
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/http/server")),
|
||||
logger: logger.With(slog.String("pkg", "github.com/SigNoz/signoz/pkg/http/server")),
|
||||
handler: handler,
|
||||
cfg: cfg,
|
||||
}, nil
|
||||
@@ -46,11 +59,18 @@ func New(logger *slog.Logger, cfg Config, handler http.Handler) (*Server, error)
|
||||
|
||||
func (server *Server) Start(ctx context.Context) error {
|
||||
server.logger.InfoContext(ctx, "starting http server", slog.String("address", server.srv.Addr))
|
||||
if err := server.srv.ListenAndServe(); err != nil {
|
||||
if err != http.ErrServerClosed {
|
||||
server.logger.ErrorContext(ctx, "failed to start server", errors.Attr(err))
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
if server.cfg.TLS.Enabled {
|
||||
// The certificate is already loaded in TLSConfig, so ListenAndServeTLS needs no file paths.
|
||||
err = server.srv.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
err = server.srv.ListenAndServe()
|
||||
}
|
||||
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
server.logger.ErrorContext(ctx, "failed to start server", errors.Attr(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
243
pkg/http/server/server_test.go
Normal file
243
pkg/http/server/server_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
||||
certFile, keyFile := writeSelfSignedCert(t)
|
||||
|
||||
corruptFile := filepath.Join(t.TempDir(), "corrupt.crt")
|
||||
require.NoError(t, os.WriteFile(corruptFile, []byte("not a pem"), 0o644))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
err bool
|
||||
minVersion uint16
|
||||
}{
|
||||
{
|
||||
name: "TLSDisabled",
|
||||
config: Config{},
|
||||
},
|
||||
{
|
||||
name: "TLSDisabled_WithCertAndKey",
|
||||
config: Config{TLS: TLS{CertFile: "ignored.crt", KeyFile: "ignored.key"}},
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_WithoutCertAndKey",
|
||||
config: Config{TLS: TLS{Enabled: true}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_WithoutKey",
|
||||
config: Config{TLS: TLS{Enabled: true, CertFile: "server.crt"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_WithoutCert",
|
||||
config: Config{TLS: TLS{Enabled: true, KeyFile: "server.key"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_InvalidMinVersion",
|
||||
config: Config{TLS: TLS{Enabled: true, CertFile: "tls.crt", KeyFile: "tls.key", MinVersion: "1.1"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_MissingFiles",
|
||||
config: Config{TLS: TLS{Enabled: true, CertFile: "missing.crt", KeyFile: "missing.key"}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_CorruptCertFile",
|
||||
config: Config{TLS: TLS{Enabled: true, CertFile: corruptFile, KeyFile: keyFile}},
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_DefaultVersions",
|
||||
config: Config{TLS: TLS{Enabled: true, CertFile: certFile, KeyFile: keyFile}},
|
||||
},
|
||||
{
|
||||
name: "TLSEnabled_WithMin",
|
||||
config: Config{TLS: TLS{Enabled: true, CertFile: certFile, KeyFile: keyFile, MinVersion: "1.3"}},
|
||||
minVersion: tls.VersionTLS13,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
server, err := New(logger, testCase.config, handler)
|
||||
if testCase.err {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if !testCase.config.TLS.Enabled {
|
||||
assert.Nil(t, server.srv.TLSConfig)
|
||||
return
|
||||
}
|
||||
|
||||
require.NotNil(t, server.srv.TLSConfig)
|
||||
assert.Len(t, server.srv.TLSConfig.Certificates, 1)
|
||||
assert.Equal(t, testCase.minVersion, server.srv.TLSConfig.MinVersion)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartWithTLS(t *testing.T) {
|
||||
certFile, keyFile := writeSelfSignedCert(t)
|
||||
addr := freeAddr(t)
|
||||
|
||||
server, err := New(
|
||||
slog.New(slog.DiscardHandler),
|
||||
Config{Address: addr, TLS: TLS{Enabled: true, CertFile: certFile, KeyFile: keyFile}},
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() { errC <- server.Start(context.Background()) }()
|
||||
|
||||
certPEM, err := os.ReadFile(certFile)
|
||||
require.NoError(t, err)
|
||||
pool := x509.NewCertPool()
|
||||
require.True(t, pool.AppendCertsFromPEM(certPEM))
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}}
|
||||
|
||||
var (
|
||||
statusCode int
|
||||
body []byte
|
||||
tlsVersion uint16
|
||||
)
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := client.Get("https://" + addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
statusCode = resp.StatusCode
|
||||
if resp.TLS != nil {
|
||||
tlsVersion = resp.TLS.Version
|
||||
}
|
||||
return true
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
assert.Equal(t, http.StatusOK, statusCode)
|
||||
assert.Equal(t, "ok", string(body))
|
||||
assert.GreaterOrEqual(t, tlsVersion, uint16(tls.VersionTLS12))
|
||||
|
||||
plainResp, err := http.Get("http://" + addr)
|
||||
require.NoError(t, err)
|
||||
_ = plainResp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, plainResp.StatusCode)
|
||||
|
||||
require.NoError(t, server.Stop(context.Background()))
|
||||
require.NoError(t, <-errC)
|
||||
}
|
||||
|
||||
func TestStartWithoutTLS(t *testing.T) {
|
||||
addr := freeAddr(t)
|
||||
|
||||
server, err := New(
|
||||
slog.New(slog.DiscardHandler),
|
||||
Config{Address: addr},
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("pong")) }),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() { errC <- server.Start(context.Background()) }()
|
||||
|
||||
var (
|
||||
statusCode int
|
||||
tlsNegotiated bool
|
||||
)
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := http.Get("http://" + addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
statusCode = resp.StatusCode
|
||||
tlsNegotiated = resp.TLS != nil
|
||||
return true
|
||||
}, 5*time.Second, 25*time.Millisecond)
|
||||
|
||||
assert.Equal(t, http.StatusOK, statusCode)
|
||||
assert.False(t, tlsNegotiated)
|
||||
|
||||
require.NoError(t, server.Stop(context.Background()))
|
||||
require.NoError(t, <-errC)
|
||||
}
|
||||
|
||||
func freeAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = listener.Close() }()
|
||||
|
||||
return listener.Addr().String()
|
||||
}
|
||||
|
||||
func writeSelfSignedCert(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "localhost"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
dir := t.TempDir()
|
||||
certFile := filepath.Join(dir, "server.crt")
|
||||
keyFile := filepath.Join(dir, "server.key")
|
||||
|
||||
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644))
|
||||
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600))
|
||||
|
||||
return certFile, keyFile
|
||||
}
|
||||
@@ -4,41 +4,17 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
)
|
||||
|
||||
type Compiled struct {
|
||||
SQL string
|
||||
Args []any
|
||||
}
|
||||
|
||||
func (c Compiled) IsEmpty() bool {
|
||||
return c.SQL == ""
|
||||
}
|
||||
|
||||
// Compile always returns a non-nil *Compiled. An empty query (or one that
|
||||
// produces no SQL) yields a Compiled with an empty SQL — callers gate on
|
||||
// SQL != "" rather than a nil check.
|
||||
//
|
||||
// A `key OP value` term compiles to a DSL predicate; a bare word is a
|
||||
// case-insensitive substring search over the dashboard name, description, and tag
|
||||
// keys/values. They compose through AND/OR/NOT, so `prod payment` matches both
|
||||
// words (implicit AND) and `prod OR name = 'x'` mixes free text with a filter. A
|
||||
// quoted token matches literally, e.g. `"prod payment"`.
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
|
||||
if len(strings.TrimSpace(query)) == 0 {
|
||||
return &Compiled{}, nil
|
||||
}
|
||||
|
||||
sql, args, errs := newVisitor(formatter).compile(query)
|
||||
// Compile wraps compiler errors in the dashboard list filter error code.
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, dashboardFieldResolver{})
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
return &Compiled{
|
||||
SQL: sql,
|
||||
Args: args,
|
||||
}, nil
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
125
pkg/modules/dashboard/impldashboard/listfilter_resolver.go
Normal file
125
pkg/modules/dashboard/impldashboard/listfilter_resolver.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// dashboardFieldResolver maps dashboard list DSL keys; a non-reserved key is a tag key matched case-insensitively.
|
||||
type dashboardFieldResolver struct{}
|
||||
|
||||
func (r dashboardFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, rawKey string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
key := strings.ToLower(rawKey)
|
||||
|
||||
if allowedOperations, isReserved := dashboardtypes.ReservedOps[dashboardtypes.DSLKey(key)]; isReserved {
|
||||
return r.resolveReservedKey(v, ctx, operation, dashboardtypes.DSLKey(key), allowedOperations)
|
||||
}
|
||||
|
||||
if _, allowed := dashboardtypes.TagKeyOps[operation]; !allowed {
|
||||
v.AddError("operator %s is not allowed on a tag-key filter", sqlcompiler.OperationName(operation))
|
||||
return ""
|
||||
}
|
||||
return r.buildTagComparison(v, ctx, operation, key)
|
||||
}
|
||||
|
||||
func (r dashboardFieldResolver) resolveReservedKey(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
|
||||
if _, allowed := allowedOperations[operation]; !allowed {
|
||||
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
|
||||
return ""
|
||||
}
|
||||
switch key {
|
||||
case dashboardtypes.DSLKeyName:
|
||||
columnExpression := string(v.Formatter.JSONExtractString("dashboard.data", "$.spec.display.name"))
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, string(key))
|
||||
case dashboardtypes.DSLKeyDescription:
|
||||
columnExpression := string(v.Formatter.JSONExtractString("dashboard.data", "$.spec.display.description"))
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, string(key))
|
||||
case dashboardtypes.DSLKeyCreatedAt:
|
||||
return v.BuildTimestampComparison(ctx, operation, "dashboard.created_at")
|
||||
case dashboardtypes.DSLKeyUpdatedAt:
|
||||
return v.BuildTimestampComparison(ctx, operation, "dashboard.updated_at")
|
||||
case dashboardtypes.DSLKeyCreatedBy:
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "dashboard.created_by", string(key))
|
||||
case dashboardtypes.DSLKeyLocked:
|
||||
return v.BuildBoolComparison(ctx, operation, "dashboard.locked")
|
||||
}
|
||||
v.AddError("no handler for reserved key %q", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (dashboardFieldResolver) buildTagComparison(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
|
||||
subqueryBuilder := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
if operation == qbtypesv5.FilterOperatorExists || operation == qbtypesv5.FilterOperatorNotExists {
|
||||
buildSubqueryForTagKey(subqueryBuilder, tagKey)
|
||||
} else {
|
||||
// Value predicates take the positive operator; negation toggles the EXISTS wrapper.
|
||||
positiveOperation := operation
|
||||
if operation.IsNegativeOperator() {
|
||||
positiveOperation = operation.Inverse()
|
||||
}
|
||||
valuePredicate := v.BuildStringOperation(subqueryBuilder, ctx, positiveOperation, "t.value", tagKey)
|
||||
if valuePredicate == "" {
|
||||
return ""
|
||||
}
|
||||
buildSubqueryForTagKeyAndValue(subqueryBuilder, tagKey, valuePredicate)
|
||||
}
|
||||
|
||||
if operation.IsNegativeOperator() {
|
||||
return v.Sb.NotExists(subqueryBuilder)
|
||||
}
|
||||
return v.Sb.Exists(subqueryBuilder)
|
||||
}
|
||||
|
||||
func buildSubqueryForTagKey(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey string) *sqlbuilder.SelectBuilder {
|
||||
const dashboardTagKind = `"dashboard"`
|
||||
|
||||
return subqueryBuilder.
|
||||
Select("1").
|
||||
From("tag_relation tr").
|
||||
Join("tag t", "t.id = tr.tag_id").
|
||||
Where(
|
||||
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
|
||||
"tr.resource_id = dashboard.id",
|
||||
"LOWER(t.key) = LOWER("+subqueryBuilder.Var(tagKey)+")",
|
||||
)
|
||||
}
|
||||
|
||||
func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey, valuePredicate string) *sqlbuilder.SelectBuilder {
|
||||
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
|
||||
}
|
||||
|
||||
// ResolveFreeText searches name, description and tag keys/values.
|
||||
func (dashboardFieldResolver) ResolveFreeText(v *sqlcompiler.Visitor, value string) string {
|
||||
nameColumn := string(v.Formatter.JSONExtractString("dashboard.data", "$.spec.display.name"))
|
||||
descriptionColumn := string(v.Formatter.JSONExtractString("dashboard.data", "$.spec.display.description"))
|
||||
namePredicate := v.BuildFreeTextContains(v.Sb, nameColumn, value)
|
||||
descriptionPredicate := v.BuildFreeTextContains(v.Sb, descriptionColumn, value)
|
||||
|
||||
subqueryBuilder := sqlbuilder.NewSelectBuilder()
|
||||
keyPredicate := v.BuildFreeTextContains(subqueryBuilder, "t.key", value)
|
||||
valuePredicate := v.BuildFreeTextContains(subqueryBuilder, "t.value", value)
|
||||
buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate)
|
||||
tagPredicate := v.Sb.Exists(subqueryBuilder)
|
||||
|
||||
return v.Sb.Or(namePredicate, descriptionPredicate, tagPredicate)
|
||||
}
|
||||
|
||||
func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder {
|
||||
const dashboardTagKind = `"dashboard"`
|
||||
|
||||
return subqueryBuilder.
|
||||
Select("1").
|
||||
From("tag_relation tr").
|
||||
Join("tag t", "t.id = tr.tag_id").
|
||||
Where(
|
||||
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
|
||||
"tr.resource_id = dashboard.id",
|
||||
subqueryBuilder.Or(keyPredicate, valuePredicate),
|
||||
)
|
||||
}
|
||||
@@ -559,6 +559,16 @@ func TestCompile_Rejections(t *testing.T) {
|
||||
dslQueryToCompile: `created_at >= 'not-a-date'`,
|
||||
expectedErrShouldContain: "RFC3339",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects LIKE pattern ending in an unescaped backslash",
|
||||
dslQueryToCompile: `name LIKE 'prod\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects ILIKE pattern ending in an unescaped backslash",
|
||||
dslQueryToCompile: `name ILIKE '%\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
{
|
||||
subtestName: "rejects REGEXP — not yet supported",
|
||||
dslQueryToCompile: `name REGEXP '.*'`,
|
||||
@@ -572,8 +582,19 @@ func TestCompile_Rejections(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileTrailingLiteralBackslash(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "escaped trailing backslash compiles",
|
||||
dslQueryToCompile: `name LIKE '%\\\\'`,
|
||||
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ? ESCAPE '\'`,
|
||||
expectedArgs: []any{`%\\`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Every key in dashboardtypes.ReservedOps must have a matching case in
|
||||
// visitComparisonForReservedKeys; a key that's reserved but unhandled falls
|
||||
// resolveReservedKey; a key that's reserved but unhandled falls
|
||||
// through to the "no handler for reserved key" error. Equal is accepted by all
|
||||
// reserved keys, so `key = 'x'` always reaches the dispatch switch — a missing
|
||||
// handler surfaces as that error regardless of whether the value type-checks.
|
||||
@@ -583,7 +604,7 @@ func TestCompileReservedKeysAllHandled(t *testing.T) {
|
||||
_, err := Compile(string(key)+` = 'x'`, formatter(t))
|
||||
if err != nil {
|
||||
assert.NotContains(t, err.Error(), "no handler for reserved key",
|
||||
"reserved key %q has no handler in visitComparisonForReservedKeys", key)
|
||||
"reserved key %q has no handler in resolveReservedKey", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// bunPlaceholderFlavor is any flavor that renders `?` placeholders, which bun
|
||||
// re-binds to the actual backend (e.g. `$1` for Postgres) at query time.
|
||||
const bunPlaceholderFlavor = sqlbuilder.SQLite
|
||||
|
||||
type visitor struct {
|
||||
grammar.BaseFilterQueryVisitor
|
||||
selectBuilder *sqlbuilder.SelectBuilder
|
||||
formatter sqlstore.SQLFormatter
|
||||
errors []string
|
||||
}
|
||||
|
||||
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
|
||||
return &visitor{
|
||||
selectBuilder: sqlbuilder.NewSelectBuilder(),
|
||||
formatter: formatter,
|
||||
}
|
||||
}
|
||||
|
||||
// compile builds `?`-placeholder WHERE SQL + args for bun. Each term is either a
|
||||
// `key OP value` comparison or a bare token that becomes a free-text search; the
|
||||
// two compose through the boolean grammar (AND/OR/NOT). Malformed input is
|
||||
// returned as errors.
|
||||
func (v *visitor) compile(query string) (string, []any, []string) {
|
||||
tree, _, collector := filterquery.Parse(query)
|
||||
if len(collector.Errors) > 0 {
|
||||
return "", nil, collector.Errors
|
||||
}
|
||||
condition, _ := v.visit(tree).(string)
|
||||
if len(v.errors) > 0 {
|
||||
return "", nil, v.errors
|
||||
}
|
||||
if condition == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
sql, arguments := v.selectBuilder.Args.CompileWithFlavor(condition, bunPlaceholderFlavor)
|
||||
return sql, arguments, nil
|
||||
}
|
||||
|
||||
func (v *visitor) visit(tree antlr.ParseTree) any {
|
||||
if tree == nil {
|
||||
return nil
|
||||
}
|
||||
return tree.Accept(v)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// methods from grammar.BaseFilterQueryVisitor that are overridden
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
|
||||
return v.visit(ctx.Expression())
|
||||
}
|
||||
|
||||
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
|
||||
return v.visit(ctx.OrExpression())
|
||||
}
|
||||
|
||||
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
|
||||
parts := ctx.AllAndExpression()
|
||||
conditions := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if condition, ok := v.visit(part).(string); ok && condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
}
|
||||
switch len(conditions) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return conditions[0]
|
||||
default:
|
||||
return v.selectBuilder.Or(conditions...)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
|
||||
parts := ctx.AllUnaryExpression()
|
||||
conditions := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if condition, ok := v.visit(part).(string); ok && condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
}
|
||||
switch len(conditions) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return conditions[0]
|
||||
default:
|
||||
return v.selectBuilder.And(conditions...)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
|
||||
condition, _ := v.visit(ctx.Primary()).(string)
|
||||
if condition == "" {
|
||||
return ""
|
||||
}
|
||||
if ctx.NOT() != nil {
|
||||
return fmt.Sprintf("NOT (%s)", condition)
|
||||
}
|
||||
return condition
|
||||
}
|
||||
|
||||
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
|
||||
if ctx.OrExpression() != nil {
|
||||
return v.visit(ctx.OrExpression())
|
||||
}
|
||||
if ctx.Comparison() != nil {
|
||||
return v.visit(ctx.Comparison())
|
||||
}
|
||||
// A lone key/value/full-text token is a free-text term, composed with any
|
||||
// comparisons through the boolean grammar. A quoted token matches its contents
|
||||
// literally — the escape hatch for a phrase or a term that looks like DSL.
|
||||
return v.buildFreeTextTerm(trimQuotes(ctx.GetText()))
|
||||
}
|
||||
|
||||
// VisitComparison dispatches a single `key OP value` term. A key that matches
|
||||
// a reserved DSL key (name, description, etc.) becomes a column-level
|
||||
// predicate; any other identifier is treated as a tag key — the operator
|
||||
// applies to the tag's value, with a case-insensitive match on the tag's key.
|
||||
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
|
||||
key := strings.ToLower(strings.TrimSpace(ctx.Key().GetText()))
|
||||
|
||||
operation, ok := v.extractOperation(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
if allowedOperations, isReserved := dashboardtypes.ReservedOps[dashboardtypes.DSLKey(key)]; isReserved {
|
||||
return v.visitComparisonForReservedKeys(ctx, operation, dashboardtypes.DSLKey(key), allowedOperations)
|
||||
}
|
||||
return v.visitComparisonForTags(ctx, operation, key)
|
||||
}
|
||||
|
||||
func (v *visitor) visitComparisonForReservedKeys(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
|
||||
if _, allowed := allowedOperations[operation]; !allowed {
|
||||
v.addError("operator %s is not allowed for key %q", operationName(operation), key)
|
||||
return ""
|
||||
}
|
||||
switch key {
|
||||
case dashboardtypes.DSLKeyName:
|
||||
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyName, "$.spec.display.name")
|
||||
case dashboardtypes.DSLKeyDescription:
|
||||
return v.buildJSONStringComparison(ctx, operation, dashboardtypes.DSLKeyDescription, "$.spec.display.description")
|
||||
case dashboardtypes.DSLKeyCreatedAt:
|
||||
return v.buildTimestampComparison(ctx, operation, "dashboard.created_at")
|
||||
case dashboardtypes.DSLKeyUpdatedAt:
|
||||
return v.buildTimestampComparison(ctx, operation, "dashboard.updated_at")
|
||||
case dashboardtypes.DSLKeyCreatedBy:
|
||||
return v.buildStringComparison(ctx, operation, dashboardtypes.DSLKeyCreatedBy, "dashboard.created_by")
|
||||
case dashboardtypes.DSLKeyLocked:
|
||||
return v.buildBoolComparison(ctx, operation, "dashboard.locked")
|
||||
}
|
||||
// Unreachable for real input: every dashboardtypes.ReservedOps key has a case above, and
|
||||
// TestCompileReservedKeysAllHandled guards that the two stay in sync.
|
||||
v.addError("no handler for reserved key %q", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *visitor) visitComparisonForTags(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
|
||||
if _, allowed := dashboardtypes.TagKeyOps[operation]; !allowed {
|
||||
v.addError("operator %s is not allowed on a tag-key filter", operationName(operation))
|
||||
return ""
|
||||
}
|
||||
return v.buildTagComparison(ctx, operation, tagKey)
|
||||
}
|
||||
|
||||
func (v *visitor) extractOperation(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
|
||||
// For operators that take an optional leading NOT, Inverse() maps each to
|
||||
// its Not<X> counterpart.
|
||||
maybeNot := func(operation qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
|
||||
if ctx.NOT() != nil {
|
||||
return operation.Inverse()
|
||||
}
|
||||
return operation
|
||||
}
|
||||
switch {
|
||||
case ctx.EQUALS() != nil:
|
||||
return qbtypesv5.FilterOperatorEqual, true
|
||||
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
|
||||
return qbtypesv5.FilterOperatorNotEqual, true
|
||||
case ctx.LT() != nil:
|
||||
return qbtypesv5.FilterOperatorLessThan, true
|
||||
case ctx.LE() != nil:
|
||||
return qbtypesv5.FilterOperatorLessThanOrEq, true
|
||||
case ctx.GT() != nil:
|
||||
return qbtypesv5.FilterOperatorGreaterThan, true
|
||||
case ctx.GE() != nil:
|
||||
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
|
||||
case ctx.BETWEEN() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorBetween), true
|
||||
case ctx.LIKE() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorLike), true
|
||||
case ctx.ILIKE() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorILike), true
|
||||
case ctx.CONTAINS() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorContains), true
|
||||
case ctx.REGEXP() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorRegexp), true
|
||||
case ctx.InClause() != nil:
|
||||
return qbtypesv5.FilterOperatorIn, true
|
||||
case ctx.NotInClause() != nil:
|
||||
return qbtypesv5.FilterOperatorNotIn, true
|
||||
case ctx.EXISTS() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorExists), true
|
||||
}
|
||||
v.addError("could not determine operator in expression %q", ctx.GetText())
|
||||
return qbtypesv5.FilterOperatorUnknown, false
|
||||
}
|
||||
|
||||
// ─── per-key emitters ────────────────────────────────────────────────────────
|
||||
|
||||
func (v *visitor) buildJSONStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, jsonPath string) string {
|
||||
columnExpression := string(v.formatter.JSONExtractString("dashboard.data", jsonPath))
|
||||
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
|
||||
}
|
||||
|
||||
func (v *visitor) buildStringComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key dashboardtypes.DSLKey, columnExpression string) string {
|
||||
return v.buildStringOperation(v.selectBuilder, ctx, operation, columnExpression, string(key))
|
||||
}
|
||||
|
||||
// buildStringOperation covers all the operators the spec allows on text-shaped keys
|
||||
// (name, description, created_by, and a tag's value). Placeholders are interned
|
||||
// into builder — the outer builder for column predicates, the subquery builder for
|
||||
// tag-value predicates — so nested EXISTS arguments thread correctly.
|
||||
func (v *visitor) buildStringOperation(builder *sqlbuilder.SelectBuilder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression, keyForError string) string {
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
val, ok := v.extractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return builder.Equal(columnExpression, val)
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
val, ok := v.extractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return builder.NotEqual(columnExpression, val)
|
||||
case qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
|
||||
val, ok := v.extractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
like := "LIKE"
|
||||
if operation == qbtypesv5.FilterOperatorNotLike {
|
||||
like = "NOT LIKE"
|
||||
}
|
||||
// The user's % and _ stay as wildcards; ESCAPE pins backslash as the escape
|
||||
// char so a literal `\` in the pattern is read the same on both dialects —
|
||||
// Postgres defaults to `\`, SQLite has no default escape.
|
||||
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var(val))
|
||||
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
|
||||
val, ok := v.extractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// SQLite has no ILIKE keyword and Postgres LIKE is case-sensitive — emit
|
||||
// LOWER(col) LIKE LOWER(?) so behavior is identical on both dialects. ESCAPE
|
||||
// pins backslash as the escape char (Postgres default; SQLite has none).
|
||||
lowerColumn := string(v.formatter.LowerExpression(columnExpression))
|
||||
like := "LIKE"
|
||||
if operation == qbtypesv5.FilterOperatorNotILike {
|
||||
like = "NOT LIKE"
|
||||
}
|
||||
return fmt.Sprintf("%s %s LOWER(%s) ESCAPE '\\'", lowerColumn, like, builder.Var(val))
|
||||
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
|
||||
val, ok := v.extractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
like := "LIKE"
|
||||
if operation == qbtypesv5.FilterOperatorNotContains {
|
||||
like = "NOT LIKE"
|
||||
}
|
||||
// Escape the user's % and _ so they match literally, then wrap in wildcards.
|
||||
// ESCAPE declares the backslash the escaper injected as the escape char —
|
||||
// needed on SQLite (no default) and a harmless restatement of the Postgres default.
|
||||
escaped := v.formatter.EscapeLikePattern(val)
|
||||
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, builder.Var("%"+escaped+"%"))
|
||||
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
|
||||
v.addError("REGEXP filtering on %q is not yet supported", keyForError)
|
||||
return ""
|
||||
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
|
||||
values, ok := v.extractStringValueList(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
arguments := make([]any, len(values))
|
||||
for i, s := range values {
|
||||
arguments[i] = s
|
||||
}
|
||||
if operation == qbtypesv5.FilterOperatorNotIn {
|
||||
return builder.NotIn(columnExpression, arguments...)
|
||||
}
|
||||
return builder.In(columnExpression, arguments...)
|
||||
}
|
||||
v.addError("operator %s on %q is not implemented", operationName(operation), keyForError)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *visitor) buildTimestampComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
|
||||
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
t, ok := v.extractSingleTimestampValue(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
return v.selectBuilder.Equal(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
return v.selectBuilder.NotEqual(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorLessThan:
|
||||
return v.selectBuilder.LessThan(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorLessThanOrEq:
|
||||
return v.selectBuilder.LessEqualThan(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorGreaterThan:
|
||||
return v.selectBuilder.GreaterThan(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
return v.selectBuilder.GreaterEqualThan(columnExpression, t)
|
||||
}
|
||||
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
|
||||
timestamps, ok := v.extractTwoTimestampValues(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if operation == qbtypesv5.FilterOperatorNotBetween {
|
||||
return v.selectBuilder.NotBetween(columnExpression, timestamps[0], timestamps[1])
|
||||
}
|
||||
return v.selectBuilder.Between(columnExpression, timestamps[0], timestamps[1])
|
||||
}
|
||||
v.addError("operator %s on timestamp is not implemented", operationName(operation))
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *visitor) buildBoolComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
|
||||
b, ok := v.extractSingleBoolValue(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if operation == qbtypesv5.FilterOperatorNotEqual {
|
||||
return v.selectBuilder.NotEqual(columnExpression, b)
|
||||
}
|
||||
return v.selectBuilder.Equal(columnExpression, b)
|
||||
}
|
||||
|
||||
func (v *visitor) buildTagComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, tagKey string) string {
|
||||
subqueryBuilder := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
if operation == qbtypesv5.FilterOperatorExists || operation == qbtypesv5.FilterOperatorNotExists {
|
||||
buildSubqueryForTagKey(subqueryBuilder, tagKey)
|
||||
} else {
|
||||
// All other tag operators take the positive form of the value predicate
|
||||
// and toggle the EXISTS wrapper for negation. Inverse() flips Not<X> → <X>.
|
||||
positiveOperation := operation
|
||||
if operation.IsNegativeOperator() {
|
||||
positiveOperation = operation.Inverse()
|
||||
}
|
||||
valuePredicate := v.buildStringOperation(subqueryBuilder, ctx, positiveOperation, "t.value", tagKey)
|
||||
if valuePredicate == "" {
|
||||
return ""
|
||||
}
|
||||
buildSubqueryForTagKeyAndValue(subqueryBuilder, tagKey, valuePredicate)
|
||||
}
|
||||
|
||||
if operation.IsNegativeOperator() {
|
||||
return v.selectBuilder.NotExists(subqueryBuilder)
|
||||
}
|
||||
return v.selectBuilder.Exists(subqueryBuilder)
|
||||
}
|
||||
|
||||
func buildSubqueryForTagKey(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey string) *sqlbuilder.SelectBuilder {
|
||||
const dashboardTagKind = `"dashboard"`
|
||||
|
||||
return subqueryBuilder.
|
||||
Select("1").
|
||||
From("tag_relation tr").
|
||||
Join("tag t", "t.id = tr.tag_id").
|
||||
Where(
|
||||
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
|
||||
"tr.resource_id = dashboard.id",
|
||||
"LOWER(t.key) = LOWER("+subqueryBuilder.Var(tagKey)+")",
|
||||
)
|
||||
}
|
||||
|
||||
func buildSubqueryForTagKeyAndValue(subqueryBuilder *sqlbuilder.SelectBuilder, tagKey, valuePredicate string) *sqlbuilder.SelectBuilder {
|
||||
return buildSubqueryForTagKey(subqueryBuilder, tagKey).Where(valuePredicate)
|
||||
}
|
||||
|
||||
// ─── free-text search ────────────────────────────────────────────────────────
|
||||
|
||||
// buildFreeTextTerm matches value as a case-insensitive substring of the
|
||||
// dashboard name, description, or any tag key/value.
|
||||
func (v *visitor) buildFreeTextTerm(value string) string {
|
||||
nameColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.name"))
|
||||
descriptionColumn := string(v.formatter.JSONExtractString("dashboard.data", "$.spec.display.description"))
|
||||
namePredicate := v.buildFreeTextContains(v.selectBuilder, nameColumn, value)
|
||||
descriptionPredicate := v.buildFreeTextContains(v.selectBuilder, descriptionColumn, value)
|
||||
|
||||
subqueryBuilder := sqlbuilder.NewSelectBuilder()
|
||||
keyPredicate := v.buildFreeTextContains(subqueryBuilder, "t.key", value)
|
||||
valuePredicate := v.buildFreeTextContains(subqueryBuilder, "t.value", value)
|
||||
buildSubqueryForFreeTextTag(subqueryBuilder, keyPredicate, valuePredicate)
|
||||
tagPredicate := v.selectBuilder.Exists(subqueryBuilder)
|
||||
|
||||
return v.selectBuilder.Or(namePredicate, descriptionPredicate, tagPredicate)
|
||||
}
|
||||
|
||||
// buildFreeTextContains emits a case-insensitive contains as
|
||||
// LOWER(COALESCE(col, '')) LIKE LOWER(?), identical on SQLite and Postgres.
|
||||
// COALESCE keeps a NULL column (an absent description) false rather than NULL —
|
||||
// otherwise `NOT (…)` goes NULL and drops every description-less dashboard. The
|
||||
// value's % and _ are escaped, and ESCAPE pins backslash as the escape char.
|
||||
func (v *visitor) buildFreeTextContains(builder *sqlbuilder.SelectBuilder, columnExpression, value string) string {
|
||||
lowerColumn := string(v.formatter.LowerExpression("COALESCE(" + columnExpression + ", '')"))
|
||||
pattern := "%" + v.formatter.EscapeLikePattern(value) + "%"
|
||||
return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, builder.Var(pattern))
|
||||
}
|
||||
|
||||
func buildSubqueryForFreeTextTag(subqueryBuilder *sqlbuilder.SelectBuilder, keyPredicate, valuePredicate string) *sqlbuilder.SelectBuilder {
|
||||
const dashboardTagKind = `"dashboard"`
|
||||
|
||||
return subqueryBuilder.
|
||||
Select("1").
|
||||
From("tag_relation tr").
|
||||
Join("tag t", "t.id = tr.tag_id").
|
||||
Where(
|
||||
subqueryBuilder.Equal("tr.kind", dashboardTagKind),
|
||||
"tr.resource_id = dashboard.id",
|
||||
subqueryBuilder.Or(keyPredicate, valuePredicate),
|
||||
)
|
||||
}
|
||||
|
||||
// ─── value extraction helpers ───────────────────────────────────────────────
|
||||
|
||||
func (v *visitor) addError(format string, arguments ...any) {
|
||||
v.errors = append(v.errors, fmt.Sprintf(format, arguments...))
|
||||
}
|
||||
|
||||
func (v *visitor) extractSingleStringValue(ctx *grammar.ComparisonContext, keyForError string) (string, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.addError("expected exactly one value for %q", keyForError)
|
||||
return "", false
|
||||
}
|
||||
return v.extractStringValue(values[0], keyForError)
|
||||
}
|
||||
|
||||
func (v *visitor) extractSingleBoolValue(ctx *grammar.ComparisonContext) (bool, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.addError("expected a single boolean (true/false)")
|
||||
return false, false
|
||||
}
|
||||
return v.extractBoolValue(values[0])
|
||||
}
|
||||
|
||||
func (v *visitor) extractSingleTimestampValue(ctx *grammar.ComparisonContext) (time.Time, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.addError("expected a single RFC3339 timestamp")
|
||||
return time.Time{}, false
|
||||
}
|
||||
return v.extractTimestampValue(values[0])
|
||||
}
|
||||
|
||||
func (v *visitor) extractTwoTimestampValues(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 2 {
|
||||
v.addError("BETWEEN expects two RFC3339 timestamps")
|
||||
return [2]time.Time{}, false
|
||||
}
|
||||
a, ok1 := v.extractTimestampValue(values[0])
|
||||
b, ok2 := v.extractTimestampValue(values[1])
|
||||
if !ok1 || !ok2 {
|
||||
return [2]time.Time{}, false
|
||||
}
|
||||
return [2]time.Time{a, b}, true
|
||||
}
|
||||
|
||||
func (v *visitor) extractStringValueList(ctx *grammar.ComparisonContext, keyForError string) ([]string, bool) {
|
||||
var valuesCtx []grammar.IValueContext
|
||||
switch {
|
||||
case ctx.InClause() != nil:
|
||||
inClause := ctx.InClause()
|
||||
if inClause.ValueList() != nil {
|
||||
valuesCtx = inClause.ValueList().AllValue()
|
||||
} else {
|
||||
valuesCtx = []grammar.IValueContext{inClause.Value()}
|
||||
}
|
||||
case ctx.NotInClause() != nil:
|
||||
notInClause := ctx.NotInClause()
|
||||
if notInClause.ValueList() != nil {
|
||||
valuesCtx = notInClause.ValueList().AllValue()
|
||||
} else {
|
||||
valuesCtx = []grammar.IValueContext{notInClause.Value()}
|
||||
}
|
||||
default:
|
||||
v.addError("IN clause is missing for %q", keyForError)
|
||||
return nil, false
|
||||
}
|
||||
if len(valuesCtx) == 0 {
|
||||
v.addError("IN list for %q is empty", keyForError)
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(valuesCtx))
|
||||
for _, valueContext := range valuesCtx {
|
||||
s, ok := v.extractStringValue(valueContext, keyForError)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (v *visitor) extractStringValue(ctx grammar.IValueContext, keyForError string) (string, bool) {
|
||||
if ctx.QUOTED_TEXT() != nil {
|
||||
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
|
||||
}
|
||||
if ctx.KEY() != nil {
|
||||
// Bare tokens are accepted as strings, mirroring the FilterQuery lexer's
|
||||
// treatment of unquoted identifiers on the value side.
|
||||
return ctx.KEY().GetText(), true
|
||||
}
|
||||
v.addError("expected a string value for %q, got %q", keyForError, ctx.GetText())
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (v *visitor) extractBoolValue(ctx grammar.IValueContext) (bool, bool) {
|
||||
if ctx.BOOL() == nil {
|
||||
v.addError("expected a boolean (true/false), got %q", ctx.GetText())
|
||||
return false, false
|
||||
}
|
||||
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
|
||||
}
|
||||
|
||||
func (v *visitor) extractTimestampValue(ctx grammar.IValueContext) (time.Time, bool) {
|
||||
if ctx.QUOTED_TEXT() == nil {
|
||||
v.addError("expected an RFC3339 timestamp string, got %q", ctx.GetText())
|
||||
return time.Time{}, false
|
||||
}
|
||||
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
v.addError("invalid RFC3339 timestamp %q: %s", raw, err.Error())
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// ─── operator spelling ───────────────────────────────────────────────────────
|
||||
|
||||
// operationName returns the user-facing spelling of a FilterOperator, used only in
|
||||
// error messages — go-sqlbuilder's Cond helpers emit the SQL keywords.
|
||||
func operationName(operation qbtypesv5.FilterOperator) string {
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
return "="
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
return "!="
|
||||
case qbtypesv5.FilterOperatorLessThan:
|
||||
return "<"
|
||||
case qbtypesv5.FilterOperatorLessThanOrEq:
|
||||
return "<="
|
||||
case qbtypesv5.FilterOperatorGreaterThan:
|
||||
return ">"
|
||||
case qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
return ">="
|
||||
case qbtypesv5.FilterOperatorBetween:
|
||||
return "BETWEEN"
|
||||
case qbtypesv5.FilterOperatorNotBetween:
|
||||
return "NOT BETWEEN"
|
||||
case qbtypesv5.FilterOperatorLike:
|
||||
return "LIKE"
|
||||
case qbtypesv5.FilterOperatorNotLike:
|
||||
return "NOT LIKE"
|
||||
case qbtypesv5.FilterOperatorILike:
|
||||
return "ILIKE"
|
||||
case qbtypesv5.FilterOperatorNotILike:
|
||||
return "NOT ILIKE"
|
||||
case qbtypesv5.FilterOperatorContains:
|
||||
return "CONTAINS"
|
||||
case qbtypesv5.FilterOperatorNotContains:
|
||||
return "NOT CONTAINS"
|
||||
case qbtypesv5.FilterOperatorRegexp:
|
||||
return "REGEXP"
|
||||
case qbtypesv5.FilterOperatorNotRegexp:
|
||||
return "NOT REGEXP"
|
||||
case qbtypesv5.FilterOperatorIn:
|
||||
return "IN"
|
||||
case qbtypesv5.FilterOperatorNotIn:
|
||||
return "NOT IN"
|
||||
case qbtypesv5.FilterOperatorExists:
|
||||
return "EXISTS"
|
||||
case qbtypesv5.FilterOperatorNotExists:
|
||||
return "NOT EXISTS"
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
func trimQuotes(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
s = s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
s = strings.ReplaceAll(s, `\\`, `\`)
|
||||
s = strings.ReplaceAll(s, `\'`, `'`)
|
||||
return s
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -17,11 +16,10 @@ type setter struct {
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
dashboard dashboard.Module
|
||||
spanMapper spanmapper.Module
|
||||
}
|
||||
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module, spanMapper spanmapper.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard, spanMapper: spanMapper}
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
|
||||
}
|
||||
|
||||
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
|
||||
@@ -45,10 +43,6 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.spanMapper.ReconcileSystemGroups(ctx, organization.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*.json
|
||||
var definitionFiles embed.FS
|
||||
|
||||
// NewSystemGroupRegistry parses every embedded definition. Definitions are
|
||||
// build-time assets validated by a test, so a failure here means the binary
|
||||
// shipped broken JSON.
|
||||
func NewSystemGroupRegistry() (spantypes.SpanMapperGroupRegistry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read span mapper group definitions")
|
||||
}
|
||||
|
||||
definitions := make([]spantypes.SpanMapperGroupDefinition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
file := path.Join(definitionsRoot, entry.Name())
|
||||
raw, err := definitionFiles.ReadFile(file)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
|
||||
}
|
||||
|
||||
definition, err := spantypes.NewSpanMapperGroupDefinition(raw)
|
||||
if err != nil {
|
||||
return spantypes.SpanMapperGroupRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
|
||||
return spantypes.NewSpanMapperGroupRegistry(definitions)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "agent",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "agent"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.agent.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "agent_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.agent.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.agent.description",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "agent.description",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.output.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "final_result",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "llm",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "model"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.request.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.model_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 60
|
||||
},
|
||||
{
|
||||
"key": "llm.request.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "ai.model.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "langfuse.observation.model.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "embedding.model_name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.response.model",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.response.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.response.model",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.provider.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.system",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.vendor",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.provider",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.system",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.model.provider",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.operation.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "llm.request.type",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.prompt_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.usage.prompt_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.inputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.promptTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.output_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.completion_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 50
|
||||
},
|
||||
{
|
||||
"key": "llm.usage.completion_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.completion",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.outputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.completionTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.cache_read.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.cache_read_input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt_details.cache_read",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.usage.cachedInputTokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.usage.cache_creation.input_tokens",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.usage.cache_write.input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gen_ai.usage.cache_creation_input_tokens",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "llm.token_count.prompt_details.cache_write",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.input.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.prompt",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.prompt.messages",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "input.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.output.messages",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "gen_ai.completion",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "ai.response.text",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "output.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.conversation.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "session.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "langfuse.session.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.response.finish_reason",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.response.finishReason",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"name": "tool",
|
||||
"condition": {
|
||||
"attributes": [
|
||||
{
|
||||
"value": "tool"
|
||||
}
|
||||
],
|
||||
"resource": []
|
||||
},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{
|
||||
"name": "gen_ai.tool.name",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.toolCall.name",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.id",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "ai.toolCall.id",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.description",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "tool.description",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.arguments",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.toolCall.args",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "traceloop.entity.input",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gcp.vertex.agent.tool_call_args",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "input.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gen_ai.tool.call.result",
|
||||
"fieldContext": "attribute",
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"key": "ai.toolCall.result",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 40
|
||||
},
|
||||
{
|
||||
"key": "traceloop.entity.output",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 30
|
||||
},
|
||||
{
|
||||
"key": "gcp.vertex.agent.tool_response",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 20
|
||||
},
|
||||
{
|
||||
"key": "output.value",
|
||||
"context": "attribute",
|
||||
"operation": "copy",
|
||||
"priority": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -69,10 +69,6 @@ func (h *handler) CreateGroup(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
group := spantypes.NewSpanMapperGroup(orgID, claims.Email, req)
|
||||
|
||||
@@ -195,10 +191,6 @@ func (h *handler) CreateMapper(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
mapper := spantypes.NewSpanMapper(groupID, claims.Email, req)
|
||||
|
||||
if err := h.module.CreateMapper(ctx, orgID, groupID, mapper); err != nil {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
@@ -15,24 +14,13 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
type module struct {
|
||||
store spantypes.SpanMapperStore
|
||||
flagger flagger.Flagger
|
||||
registry spantypes.SpanMapperGroupRegistry
|
||||
settings factory.ScopedProviderSettings
|
||||
store spantypes.SpanMapperStore
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger, registry spantypes.SpanMapperGroupRegistry, providerSettings factory.ProviderSettings) spanmapper.Module {
|
||||
return &module{
|
||||
store: store,
|
||||
flagger: flagger,
|
||||
registry: registry,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
|
||||
}
|
||||
func NewModule(store spantypes.SpanMapperStore, flagger flagger.Flagger) spanmapper.Module {
|
||||
return &module{store: store, flagger: flagger}
|
||||
}
|
||||
|
||||
func (module *module) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
@@ -44,9 +32,6 @@ func (module *module) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spa
|
||||
}
|
||||
|
||||
func (module *module) CreateGroup(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup) error {
|
||||
if module.registry.IsReserved(group.Name) {
|
||||
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", group.Name)
|
||||
}
|
||||
return module.store.CreateGroup(ctx, group)
|
||||
}
|
||||
|
||||
@@ -55,14 +40,10 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name != nil && *name != group.Name && module.registry.IsReserved(*name) {
|
||||
return errors.Newf(errors.TypeInvalidInput, spantypes.ErrCodeMappingGroupNameReserved, "group name %q is reserved for a default group", *name)
|
||||
}
|
||||
if err := group.Update(name, condition, enabled, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
group.Update(name, condition, enabled, updatedBy)
|
||||
|
||||
if err := module.store.UpdateGroup(ctx, group); err != nil {
|
||||
err = module.store.UpdateGroup(ctx, group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
@@ -70,16 +51,10 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
}
|
||||
|
||||
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
group, err := module.store.GetGroup(ctx, orgID, id)
|
||||
err := module.store.DeleteGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := group.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := module.store.DeleteGroup(ctx, orgID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
@@ -106,13 +81,14 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
|
||||
}
|
||||
|
||||
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
|
||||
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mapper.Update(fieldContext, config, enabled, updatedBy); err != nil {
|
||||
return err
|
||||
}
|
||||
mapper.Update(fieldContext, config, enabled, updatedBy)
|
||||
err = module.store.UpdateMapper(ctx, mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -122,14 +98,7 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
|
||||
}
|
||||
|
||||
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
|
||||
mapper, err := module.store.GetMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mapper.ErrIfNotDeletable(); err != nil {
|
||||
return err
|
||||
}
|
||||
err = module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -137,6 +106,10 @@ func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id value
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxTestSpans bounds the input size: every test request boots a full
|
||||
// in-memory collector pipeline and is reachable with viewer access.
|
||||
const maxTestSpans = 100
|
||||
|
||||
func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error) {
|
||||
if len(spans) == 0 {
|
||||
return nil, nil, errors.New(errors.TypeInvalidInput, spantypes.ErrCodeMappingInvalidInput, "'spans' must contain at least one span")
|
||||
@@ -157,6 +130,37 @@ func (module *module) TestMappers(ctx context.Context, orgID valuer.UUID, spans
|
||||
return out, collectorLogs, nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return spantypes.SpanAttrMappingFeatureType
|
||||
}
|
||||
@@ -192,37 +196,6 @@ func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// backfillMappers loads saved mappers for any enabled group whose Mappers is
|
||||
// nil. Disabled groups are skipped: the simulation filters them out anyway,
|
||||
// so there is no point loading their mappers or failing on their names.
|
||||
func (module *module) backfillMappers(ctx context.Context, orgID valuer.UUID, groups []*spantypes.SpanMapperGroupWithMappers) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
savedGroups, err := module.store.ListGroups(ctx, orgID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
savedByName := make(map[string]*spantypes.SpanMapperGroup, len(savedGroups))
|
||||
for _, g := range savedGroups {
|
||||
savedByName[g.Name] = g
|
||||
}
|
||||
|
||||
// For each group in the request, if Mappers is nil, load the saved mappers for that group name.
|
||||
for _, g := range groups {
|
||||
if g.Mappers != nil || !g.Group.Enabled {
|
||||
continue
|
||||
}
|
||||
saved, ok := savedByName[g.Group.Name]
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "no saved group named %q to load mappers from; send 'mappers' for new or edited groups", g.Group.Name)
|
||||
}
|
||||
loaded, err := module.store.ListMappers(ctx, orgID, saved.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.Mappers = loaded
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// listEnabledGroupsWithMappers returns groups with their mappers.
|
||||
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
enabled := true
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testUser = "user@signoz.io"
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []any{
|
||||
(*spantypes.StorableSpanMapperGroup)(nil),
|
||||
(*spantypes.StorableSpanMapper)(nil),
|
||||
} {
|
||||
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_org_name ON span_mapper_group (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_span_mapper_group_name ON span_mapper (group_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...spantypes.SpanMapperGroupDefinition) *module {
|
||||
t.Helper()
|
||||
|
||||
registry, err := spantypes.NewSpanMapperGroupRegistry(definitions)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewModule(NewStore(sqlStore), nil, registry, factorytest.NewSettings()).(*module)
|
||||
}
|
||||
|
||||
func newTestDefinition(t *testing.T, version int, body string) spantypes.SpanMapperGroupDefinition {
|
||||
t.Helper()
|
||||
|
||||
definition, err := spantypes.NewSpanMapperGroupDefinition([]byte(`{"version": ` + strconv.Itoa(version) + `, "definition": ` + body + `}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
// llmV1 ships two mappers; llmV2 renames a source, adds a mapper and drops one.
|
||||
const llmV1 = `{
|
||||
"name": "llm",
|
||||
"condition": {"attributes": [{"value": "model"}], "resource": []},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
|
||||
{"key": "ai.model.id", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}},
|
||||
{"name": "gen_ai.input.messages", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "gen_ai.prompt", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}}
|
||||
]
|
||||
}`
|
||||
|
||||
const llmV2 = `{
|
||||
"name": "llm",
|
||||
"condition": {"attributes": [{"value": "model"}, {"value": "llm."}], "resource": []},
|
||||
"enabled": true,
|
||||
"mappers": [
|
||||
{"name": "gen_ai.request.model", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.model_name", "context": "attribute", "operation": "copy", "priority": 20},
|
||||
{"key": "langfuse.observation.model.name", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}},
|
||||
{"name": "gen_ai.provider.name", "fieldContext": "attribute", "config": {"sources": [
|
||||
{"key": "llm.vendor", "context": "attribute", "operation": "copy", "priority": 10}
|
||||
]}}
|
||||
]
|
||||
}`
|
||||
|
||||
func findMapper(t *testing.T, mappers []*spantypes.SpanMapper, name string) *spantypes.SpanMapper {
|
||||
t.Helper()
|
||||
for _, m := range mappers {
|
||||
if m.Name == name {
|
||||
return m
|
||||
}
|
||||
}
|
||||
require.Failf(t, "mapper not found", "no mapper named %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func findSource(t *testing.T, sources []spantypes.SpanMapperSource, key string, origin spantypes.SpanMapperOrigin) spantypes.SpanMapperSource {
|
||||
t.Helper()
|
||||
for _, s := range sources {
|
||||
if s.Key == key && s.Origin == origin {
|
||||
return s
|
||||
}
|
||||
}
|
||||
require.Failf(t, "source not found", "no %s source with key %q", origin.StringValue(), key)
|
||||
return spantypes.SpanMapperSource{}
|
||||
}
|
||||
|
||||
func TestReconcileUpgradeKeepsTogglesAndUserItems(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
v1 := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
|
||||
require.NoError(t, v1.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
group, err := v1.store.GetGroupByName(ctx, orgID, "llm")
|
||||
require.NoError(t, err)
|
||||
mappers, err := v1.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Switch the shipped substring off and add a user one.
|
||||
off := false
|
||||
require.NoError(t, v1.UpdateGroup(ctx, orgID, group.ID, nil, &spantypes.SpanMapperGroupCondition{
|
||||
Attributes: []spantypes.SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
},
|
||||
Resource: []spantypes.SpanMapperGroupConditionKey{},
|
||||
}, &off, testUser))
|
||||
|
||||
// Switch a shipped source off, add a user override, and switch the mapper off.
|
||||
model := findMapper(t, mappers, "gen_ai.request.model")
|
||||
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, model.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
|
||||
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 20, Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Key: "llm.model_name", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationMove, Priority: 1, Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
}}, &off, testUser))
|
||||
|
||||
// Add a user source to the mapper v2 stops shipping, so it must survive.
|
||||
messages := findMapper(t, mappers, "gen_ai.input.messages")
|
||||
require.NoError(t, v1.UpdateMapper(ctx, orgID, group.ID, messages.ID, spantypes.FieldContext{}, &spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{
|
||||
{Key: "input.value", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true},
|
||||
}}, nil, testUser))
|
||||
|
||||
// A user mapper in the shipped group.
|
||||
require.NoError(t, v1.CreateMapper(ctx, orgID, group.ID, spantypes.NewSpanMapper(group.ID, testUser, &spantypes.PostableSpanMapper{
|
||||
Name: "gen_ai.custom", FieldContext: spantypes.FieldContextSpanAttribute, Enabled: true,
|
||||
Config: spantypes.SpanMapperConfig{Sources: []spantypes.SpanMapperSource{{Key: "custom", Context: spantypes.FieldContextSpanAttribute, Operation: spantypes.SpanMapperOperationCopy, Priority: 1, Enabled: true}}},
|
||||
})))
|
||||
|
||||
v2 := newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2))
|
||||
require.NoError(t, v2.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
upgraded, err := v2.GetGroup(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, upgraded.Version)
|
||||
assert.False(t, upgraded.Enabled)
|
||||
assert.Equal(t, spantypes.ProvisionerIdentity, upgraded.UpdatedBy)
|
||||
assert.Equal(t, []spantypes.SpanMapperGroupConditionKey{
|
||||
{Value: "model", Enabled: false, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "llm.", Enabled: true, Origin: spantypes.SpanMapperOriginSystem},
|
||||
{Value: "gen_ai.request.model", Enabled: true, Origin: spantypes.SpanMapperOriginUser},
|
||||
}, upgraded.Condition.Attributes)
|
||||
|
||||
mappers, err = v2.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mappers, 4)
|
||||
|
||||
model = findMapper(t, mappers, "gen_ai.request.model")
|
||||
assert.False(t, model.Enabled)
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, model.Origin)
|
||||
assert.False(t, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginSystem).Enabled)
|
||||
assert.True(t, findSource(t, model.Config.Sources, "langfuse.observation.model.name", spantypes.SpanMapperOriginSystem).Enabled)
|
||||
assert.Equal(t, spantypes.SpanMapperOperationMove, findSource(t, model.Config.Sources, "llm.model_name", spantypes.SpanMapperOriginUser).Operation)
|
||||
assert.Len(t, model.Config.Sources, 3)
|
||||
|
||||
messages = findMapper(t, mappers, "gen_ai.input.messages")
|
||||
assert.Equal(t, spantypes.SpanMapperOriginUser, messages.Origin)
|
||||
require.Len(t, messages.Config.Sources, 1)
|
||||
assert.Equal(t, "input.value", messages.Config.Sources[0].Key)
|
||||
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, findMapper(t, mappers, "gen_ai.provider.name").Origin)
|
||||
assert.Equal(t, spantypes.SpanMapperOriginUser, findMapper(t, mappers, "gen_ai.custom").Origin)
|
||||
|
||||
// Shipping v1 again drops provider.name outright (no user sources) and
|
||||
// re-adopts the surviving user mapper input.messages as a shipped one.
|
||||
v3 := newTestModule(t, sqlStore, newTestDefinition(t, 3, llmV1))
|
||||
require.NoError(t, v3.ReconcileSystemGroups(ctx, orgID))
|
||||
mappers, err = v3.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, mappers, 3)
|
||||
for _, m := range mappers {
|
||||
assert.NotEqual(t, "gen_ai.provider.name", m.Name)
|
||||
}
|
||||
messages = findMapper(t, mappers, "gen_ai.input.messages")
|
||||
assert.Equal(t, spantypes.SpanMapperOriginSystem, messages.Origin)
|
||||
assert.Len(t, messages.Config.Sources, 2)
|
||||
}
|
||||
|
||||
func TestReconcileDoesNotDowngrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgID := valuer.GenerateUUID()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
|
||||
require.NoError(t, newTestModule(t, sqlStore, newTestDefinition(t, 2, llmV2)).ReconcileSystemGroups(ctx, orgID))
|
||||
older := newTestModule(t, sqlStore, newTestDefinition(t, 1, llmV1))
|
||||
require.NoError(t, older.ReconcileSystemGroups(ctx, orgID))
|
||||
|
||||
group, err := older.store.GetGroupByName(ctx, orgID, "llm")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, group.Version)
|
||||
mappers, err := older.ListMappers(ctx, orgID, group.ID)
|
||||
require.NoError(t, err)
|
||||
findMapper(t, mappers, "gen_ai.provider.name")
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (module *module) ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error {
|
||||
for _, definition := range module.registry.List() {
|
||||
if err := module.reconcileSystemGroup(ctx, orgID, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileSystemGroup brings one org's copy of a definition to the shipped
|
||||
// version in a single transaction. A concurrent provisioner (another replica,
|
||||
// or the org-creation hook racing the startup sweep) loses on the group's
|
||||
// unique (org_id, name) index and is treated as a no-op.
|
||||
func (module *module) reconcileSystemGroup(ctx context.Context, orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) error {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
group, err := module.store.GetGroupByName(ctx, orgID, definition.Name())
|
||||
if err != nil && errors.Ast(err, errors.TypeNotFound) {
|
||||
group = newSystemGroup(orgID, definition)
|
||||
err = module.store.CreateGroup(ctx, group)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.Origin != spantypes.SpanMapperOriginSystem {
|
||||
module.settings.Logger().WarnContext(ctx, "skipping default span mapper group: a user group holds its name", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
if group.Version >= definition.Version {
|
||||
return nil
|
||||
}
|
||||
return module.applyDefinition(ctx, orgID, group, definition)
|
||||
})
|
||||
if err != nil && errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
module.settings.Logger().DebugContext(ctx, "default span mapper group provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// applyDefinition replaces every shipped item with the definition, carrying each
|
||||
// enabled flag over by identity, and leaves user items untouched. A mapper that
|
||||
// is no longer shipped is deleted unless the user added sources to it, in which
|
||||
// case it survives as a user mapper.
|
||||
func (module *module) applyDefinition(ctx context.Context, orgID valuer.UUID, group *spantypes.SpanMapperGroup, definition spantypes.SpanMapperGroupDefinition) error {
|
||||
mappers, err := module.store.ListMappers(ctx, orgID, group.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byName := make(map[string]*spantypes.SpanMapper, len(mappers))
|
||||
for _, m := range mappers {
|
||||
byName[m.Name] = m
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range definition.Definition.Mappers {
|
||||
pm := &definition.Definition.Mappers[i]
|
||||
mapper, exists := byName[pm.Name]
|
||||
delete(byName, pm.Name)
|
||||
if !exists {
|
||||
if err := module.store.CreateMapper(ctx, newSystemMapper(group.ID, pm)); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, pm.Config.Sources)
|
||||
mapper.FieldContext = pm.FieldContext
|
||||
mapper.Origin = spantypes.SpanMapperOriginSystem
|
||||
mapper.UpdatedAt = now
|
||||
mapper.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever is left in byName is not shipped any more.
|
||||
for _, mapper := range byName {
|
||||
if mapper.Origin != spantypes.SpanMapperOriginSystem {
|
||||
continue
|
||||
}
|
||||
mapper.Config.Sources = mergeShippedSources(mapper.Config.Sources, nil)
|
||||
if len(mapper.Config.Sources) == 0 {
|
||||
if err := module.store.DeleteMapper(ctx, orgID, group.ID, mapper.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
mapper.Origin = spantypes.SpanMapperOriginUser
|
||||
mapper.UpdatedAt = now
|
||||
mapper.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateMapper(ctx, mapper); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
shipped := definition.Definition.Condition
|
||||
group.Condition = spantypes.SpanMapperGroupCondition{
|
||||
Attributes: mergeShippedConditionKeys(group.Condition.Attributes, shipped.Attributes),
|
||||
Resource: mergeShippedConditionKeys(group.Condition.Resource, shipped.Resource),
|
||||
}
|
||||
group.Version = definition.Version
|
||||
group.UpdatedAt = now
|
||||
group.UpdatedBy = spantypes.ProvisionerIdentity
|
||||
if err := module.store.UpdateGroup(ctx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.settings.Logger().InfoContext(ctx, "applied default span mapper group", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSystemGroup is the empty shell applyDefinition fills: version 0 so the
|
||||
// definition is applied right after the row exists.
|
||||
func newSystemGroup(orgID valuer.UUID, definition spantypes.SpanMapperGroupDefinition) *spantypes.SpanMapperGroup {
|
||||
group := spantypes.NewSpanMapperGroup(orgID, spantypes.ProvisionerIdentity, &definition.Definition.PostableSpanMapperGroup)
|
||||
group.Condition = definition.Definition.Condition
|
||||
group.Enabled = true
|
||||
group.Origin = spantypes.SpanMapperOriginSystem
|
||||
return group
|
||||
}
|
||||
|
||||
func newSystemMapper(groupID valuer.UUID, pm *spantypes.PostableSpanMapper) *spantypes.SpanMapper {
|
||||
mapper := spantypes.NewSpanMapper(groupID, spantypes.ProvisionerIdentity, pm)
|
||||
mapper.Config = pm.Config
|
||||
mapper.Enabled = true
|
||||
mapper.Origin = spantypes.SpanMapperOriginSystem
|
||||
return mapper
|
||||
}
|
||||
|
||||
// mergeShippedConditionKeys returns the shipped keys, each keeping the enabled
|
||||
// flag of the stored system key with the same value, followed by the stored
|
||||
// user keys.
|
||||
func mergeShippedConditionKeys(stored, shipped []spantypes.SpanMapperGroupConditionKey) []spantypes.SpanMapperGroupConditionKey {
|
||||
out := make([]spantypes.SpanMapperGroupConditionKey, 0, len(stored)+len(shipped))
|
||||
for _, k := range shipped {
|
||||
idx := slices.IndexFunc(stored, func(s spantypes.SpanMapperGroupConditionKey) bool {
|
||||
return s.Origin == spantypes.SpanMapperOriginSystem && s.Value == k.Value
|
||||
})
|
||||
if idx != -1 {
|
||||
k.Enabled = stored[idx].Enabled
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range stored {
|
||||
if k.Origin != spantypes.SpanMapperOriginSystem {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeShippedSources returns the shipped sources, each keeping the enabled
|
||||
// flag of the stored system source with the same key and context, followed by
|
||||
// the stored user sources.
|
||||
func mergeShippedSources(stored, shipped []spantypes.SpanMapperSource) []spantypes.SpanMapperSource {
|
||||
out := make([]spantypes.SpanMapperSource, 0, len(stored)+len(shipped))
|
||||
for _, s := range shipped {
|
||||
idx := slices.IndexFunc(stored, func(o spantypes.SpanMapperSource) bool {
|
||||
return o.Origin == spantypes.SpanMapperOriginSystem && o.Key == s.Key && o.Context == s.Context
|
||||
})
|
||||
if idx != -1 {
|
||||
s.Enabled = stored[idx].Enabled
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
for _, s := range stored {
|
||||
if s.Origin != spantypes.SpanMapperOriginSystem {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
)
|
||||
|
||||
const reconcileRetryInterval = 30 * time.Second
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
module spanmapper.Module
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
|
||||
// NewService reconciles every org's default mapping groups once at startup.
|
||||
// Orgs created later are reconciled by the organization setter instead.
|
||||
func NewService(providerSettings factory.ProviderSettings, module spanmapper.Module, orgGetter organization.Getter) factory.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"),
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
healthyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(reconcileRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := service.reconcile(ctx)
|
||||
if err == nil {
|
||||
close(service.healthyC)
|
||||
<-service.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
service.settings.Logger().WarnContext(ctx, "default span mapper group reconciliation failed, retrying", errors.Attr(err))
|
||||
|
||||
select {
|
||||
case <-service.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Healthy() <-chan struct{} {
|
||||
return service.healthyC
|
||||
}
|
||||
|
||||
func (service *service) Stop(_ context.Context) error {
|
||||
close(service.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) reconcile(ctx context.Context) error {
|
||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := service.module.ReconcileSystemGroups(ctx, org.ID); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile default span mapper groups for org %s", org.ID.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
service.settings.Logger().InfoContext(ctx, "default span mapper group reconciliation completed", slog.Int("orgs", len(orgs)))
|
||||
return nil
|
||||
}
|
||||
@@ -17,10 +17,6 @@ func NewStore(sqlstore sqlstore.SQLStore) spantypes.SpanMapperStore {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
|
||||
func (s *store) CreateGroup(ctx context.Context, group *spantypes.SpanMapperGroup) error {
|
||||
storable := group.ToStorable()
|
||||
_, err := s.sqlstore.
|
||||
@@ -38,7 +34,7 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
|
||||
storable := new(spantypes.StorableSpanMapperGroup)
|
||||
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
@@ -50,27 +46,11 @@ func (s *store) GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes
|
||||
return storable.ToSpanMapperGroup(), nil
|
||||
}
|
||||
|
||||
func (s *store) GetGroupByName(ctx context.Context, orgID valuer.UUID, name string) (*spantypes.SpanMapperGroup, error) {
|
||||
storable := new(spantypes.StorableSpanMapperGroup)
|
||||
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, s.sqlstore.WrapNotFoundErrf(err, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %q not found", name)
|
||||
}
|
||||
return storable.ToSpanMapperGroup(), nil
|
||||
}
|
||||
|
||||
func (s *store) ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error) {
|
||||
storables := make([]*spantypes.StorableSpanMapperGroup, 0)
|
||||
|
||||
sel := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("org_id = ?", orgID)
|
||||
@@ -111,35 +91,38 @@ func (s *store) UpdateGroup(ctx context.Context, group *spantypes.SpanMapperGrou
|
||||
}
|
||||
|
||||
func (s *store) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return s.RunInTx(ctx, func(ctx context.Context) error {
|
||||
db := s.sqlstore.BunDBCtx(ctx)
|
||||
tx, err := s.sqlstore.BunDBCtx(ctx).BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Cascade: remove mappers belonging to this group first.
|
||||
if _, err := db.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Cascade: remove mappers belonging to this group first.
|
||||
if _, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapper)(nil)).
|
||||
Where("group_id = ?", id).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := db.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapperGroup)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.NewDelete().
|
||||
Model((*spantypes.StorableSpanMapperGroup)(nil)).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("id = ?", id).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, spantypes.ErrCodeMappingGroupNotFound, "span mapper group %s not found", id)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *store) CreateMapper(ctx context.Context, mapper *spantypes.SpanMapper) error {
|
||||
@@ -163,7 +146,7 @@ func (s *store) GetMapper(ctx context.Context, orgID, groupID, id valuer.UUID) (
|
||||
|
||||
storable := new(spantypes.StorableSpanMapper)
|
||||
err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("group_id = ?", groupID).
|
||||
@@ -183,7 +166,7 @@ func (s *store) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]
|
||||
|
||||
storables := make([]*spantypes.StorableSpanMapper, 0)
|
||||
if err := s.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&storables).
|
||||
Where("group_id = ?", groupID).
|
||||
|
||||
@@ -28,10 +28,6 @@ type Module interface {
|
||||
UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error
|
||||
DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error
|
||||
TestMappers(ctx context.Context, orgID valuer.UUID, spans []spantypes.SpanMapperTestSpan, groups []*spantypes.SpanMapperGroupWithMappers) ([]spantypes.SpanMapperTestSpan, []string, error)
|
||||
|
||||
// ReconcileSystemGroups provisions or upgrades the shipped mapping groups
|
||||
// for one org. It runs at startup for every org and again on org creation.
|
||||
ReconcileSystemGroups(ctx context.Context, orgID valuer.UUID) error
|
||||
}
|
||||
|
||||
// Handler defines the HTTP handler interface for mapping group and mapper endpoints.
|
||||
|
||||
514
pkg/parser/filterquery/sqlcompiler/compiler.go
Normal file
514
pkg/parser/filterquery/sqlcompiler/compiler.go
Normal file
@@ -0,0 +1,514 @@
|
||||
// Package sqlcompiler compiles list-page filter queries to relational-store WHERE clauses; telemetry queries stay on querybuilder's ClickHouse visitor.
|
||||
package sqlcompiler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery"
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/antlr4-go/antlr/v4"
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// bunPlaceholderFlavor is any flavor that renders the `?` placeholders bun expects.
|
||||
const bunPlaceholderFlavor = sqlbuilder.SQLite
|
||||
|
||||
// FieldResolver is the per-feature policy: which keys exist and what each maps to.
|
||||
type FieldResolver interface {
|
||||
// ResolveComparison builds the predicate for one `key OP value` term; key keeps the user's casing.
|
||||
ResolveComparison(v *Visitor, key string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string
|
||||
// ResolveFreeText builds the predicate for a bare token.
|
||||
ResolveFreeText(v *Visitor, value string) string
|
||||
}
|
||||
|
||||
// Compiled is a `?`-placeholder WHERE clause with its bun bind args.
|
||||
type Compiled struct {
|
||||
SQL string
|
||||
Args []any
|
||||
}
|
||||
|
||||
func (c Compiled) IsEmpty() bool {
|
||||
return c.SQL == ""
|
||||
}
|
||||
|
||||
// Compile on success returns a non-nil *Compiled, empty for an empty query; callers gate on IsEmpty, not nil.
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter, resolver FieldResolver) (*Compiled, []string) {
|
||||
if len(strings.TrimSpace(query)) == 0 {
|
||||
return &Compiled{}, nil
|
||||
}
|
||||
|
||||
v := &Visitor{
|
||||
Sb: sqlbuilder.NewSelectBuilder(),
|
||||
Formatter: formatter,
|
||||
resolver: resolver,
|
||||
}
|
||||
|
||||
tree, _, collector := filterquery.Parse(query)
|
||||
if len(collector.Errors) > 0 {
|
||||
return nil, collector.Errors
|
||||
}
|
||||
condition, _ := v.visit(tree).(string)
|
||||
if len(v.errors) > 0 {
|
||||
return nil, v.errors
|
||||
}
|
||||
if condition == "" {
|
||||
return &Compiled{}, nil
|
||||
}
|
||||
sql, arguments := v.Sb.Args.CompileWithFlavor(condition, bunPlaceholderFlavor)
|
||||
return &Compiled{SQL: sql, Args: arguments}, nil
|
||||
}
|
||||
|
||||
// Visitor walks the parse tree and carries the per-compile SQL state; the
|
||||
// FieldResolver calls its helpers back to build each predicate.
|
||||
type Visitor struct {
|
||||
grammar.BaseFilterQueryVisitor
|
||||
Sb *sqlbuilder.SelectBuilder
|
||||
Formatter sqlstore.SQLFormatter
|
||||
errors []string
|
||||
resolver FieldResolver
|
||||
}
|
||||
|
||||
func (v *Visitor) visit(tree antlr.ParseTree) any {
|
||||
if tree == nil {
|
||||
return nil
|
||||
}
|
||||
return tree.Accept(v)
|
||||
}
|
||||
|
||||
func (v *Visitor) VisitQuery(ctx *grammar.QueryContext) any {
|
||||
return v.visit(ctx.Expression())
|
||||
}
|
||||
|
||||
func (v *Visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
|
||||
return v.visit(ctx.OrExpression())
|
||||
}
|
||||
|
||||
func (v *Visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
|
||||
parts := ctx.AllAndExpression()
|
||||
conditions := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if condition, ok := v.visit(part).(string); ok && condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
}
|
||||
switch len(conditions) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return conditions[0]
|
||||
default:
|
||||
return v.Sb.Or(conditions...)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
|
||||
parts := ctx.AllUnaryExpression()
|
||||
conditions := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if condition, ok := v.visit(part).(string); ok && condition != "" {
|
||||
conditions = append(conditions, condition)
|
||||
}
|
||||
}
|
||||
switch len(conditions) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return conditions[0]
|
||||
default:
|
||||
return v.Sb.And(conditions...)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
|
||||
condition, _ := v.visit(ctx.Primary()).(string)
|
||||
if condition == "" {
|
||||
return ""
|
||||
}
|
||||
if ctx.NOT() != nil {
|
||||
return fmt.Sprintf("NOT (%s)", condition)
|
||||
}
|
||||
return condition
|
||||
}
|
||||
|
||||
func (v *Visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
|
||||
if ctx.OrExpression() != nil {
|
||||
return v.visit(ctx.OrExpression())
|
||||
}
|
||||
if ctx.Comparison() != nil {
|
||||
return v.visit(ctx.Comparison())
|
||||
}
|
||||
// A quoted lone token matches its contents literally, the escape hatch for a phrase that looks like DSL.
|
||||
return v.resolver.ResolveFreeText(v, trimQuotes(ctx.GetText()))
|
||||
}
|
||||
|
||||
func (v *Visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
|
||||
key := strings.TrimSpace(ctx.Key().GetText())
|
||||
operation, ok := v.extractOperation(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return v.resolver.ResolveComparison(v, key, operation, ctx)
|
||||
}
|
||||
|
||||
func (v *Visitor) extractOperation(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
|
||||
maybeNot := func(operation qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
|
||||
if ctx.NOT() != nil {
|
||||
return operation.Inverse()
|
||||
}
|
||||
return operation
|
||||
}
|
||||
switch {
|
||||
case ctx.EQUALS() != nil:
|
||||
return qbtypesv5.FilterOperatorEqual, true
|
||||
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
|
||||
return qbtypesv5.FilterOperatorNotEqual, true
|
||||
case ctx.LT() != nil:
|
||||
return qbtypesv5.FilterOperatorLessThan, true
|
||||
case ctx.LE() != nil:
|
||||
return qbtypesv5.FilterOperatorLessThanOrEq, true
|
||||
case ctx.GT() != nil:
|
||||
return qbtypesv5.FilterOperatorGreaterThan, true
|
||||
case ctx.GE() != nil:
|
||||
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
|
||||
case ctx.BETWEEN() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorBetween), true
|
||||
case ctx.LIKE() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorLike), true
|
||||
case ctx.ILIKE() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorILike), true
|
||||
case ctx.CONTAINS() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorContains), true
|
||||
case ctx.REGEXP() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorRegexp), true
|
||||
case ctx.InClause() != nil:
|
||||
return qbtypesv5.FilterOperatorIn, true
|
||||
case ctx.NotInClause() != nil:
|
||||
return qbtypesv5.FilterOperatorNotIn, true
|
||||
case ctx.EXISTS() != nil:
|
||||
return maybeNot(qbtypesv5.FilterOperatorExists), true
|
||||
}
|
||||
v.AddError("could not determine operator in expression %q", ctx.GetText())
|
||||
return qbtypesv5.FilterOperatorUnknown, false
|
||||
}
|
||||
|
||||
// ─── predicate builders ──────────────────────────────────────────────────────
|
||||
|
||||
// BuildStringOperation interns placeholders into sb so nested subquery arguments thread correctly.
|
||||
func (v *Visitor) BuildStringOperation(sb *sqlbuilder.SelectBuilder, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression, keyForError string) string {
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
val, ok := v.ExtractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return sb.Equal(columnExpression, val)
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
val, ok := v.ExtractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return sb.NotEqual(columnExpression, val)
|
||||
case qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
|
||||
val, ok := v.ExtractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if endsWithDanglingEscape(val) {
|
||||
v.AddError("LIKE pattern for %q must not end with an unescaped backslash, use \\\\ to match a literal backslash", keyForError)
|
||||
return ""
|
||||
}
|
||||
like := "LIKE"
|
||||
if operation == qbtypesv5.FilterOperatorNotLike {
|
||||
like = "NOT LIKE"
|
||||
}
|
||||
// ESCAPE pins backslash as the escape char (the Postgres default, SQLite has none).
|
||||
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, sb.Var(val))
|
||||
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
|
||||
val, ok := v.ExtractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if endsWithDanglingEscape(val) {
|
||||
v.AddError("ILIKE pattern for %q must not end with an unescaped backslash, use \\\\ to match a literal backslash", keyForError)
|
||||
return ""
|
||||
}
|
||||
// SQLite has no ILIKE and Postgres LIKE is case-sensitive, so LOWER both sides.
|
||||
lowerColumn := string(v.Formatter.LowerExpression(columnExpression))
|
||||
like := "LIKE"
|
||||
if operation == qbtypesv5.FilterOperatorNotILike {
|
||||
like = "NOT LIKE"
|
||||
}
|
||||
return fmt.Sprintf("%s %s LOWER(%s) ESCAPE '\\'", lowerColumn, like, sb.Var(val))
|
||||
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
|
||||
val, ok := v.ExtractSingleStringValue(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
like := "LIKE"
|
||||
if operation == qbtypesv5.FilterOperatorNotContains {
|
||||
like = "NOT LIKE"
|
||||
}
|
||||
// Escape the user's % and _ so they match literally, then wrap in wildcards.
|
||||
escaped := v.Formatter.EscapeLikePattern(val)
|
||||
return fmt.Sprintf("%s %s %s ESCAPE '\\'", columnExpression, like, sb.Var(fmt.Sprintf("%%%s%%", escaped)))
|
||||
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
|
||||
v.AddError("REGEXP filtering on %q is not supported", keyForError)
|
||||
return ""
|
||||
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
|
||||
values, ok := v.ExtractStringValueList(ctx, keyForError)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
arguments := make([]any, len(values))
|
||||
for i, s := range values {
|
||||
arguments[i] = s
|
||||
}
|
||||
if operation == qbtypesv5.FilterOperatorNotIn {
|
||||
return sb.NotIn(columnExpression, arguments...)
|
||||
}
|
||||
return sb.In(columnExpression, arguments...)
|
||||
}
|
||||
v.AddError("operator %s on %q is not implemented", OperationName(operation), keyForError)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *Visitor) BuildTimestampComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
|
||||
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
|
||||
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
t, ok := v.extractSingleTimestampValue(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
return v.Sb.Equal(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
return v.Sb.NotEqual(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorLessThan:
|
||||
return v.Sb.LessThan(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorLessThanOrEq:
|
||||
return v.Sb.LessEqualThan(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorGreaterThan:
|
||||
return v.Sb.GreaterThan(columnExpression, t)
|
||||
case qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
return v.Sb.GreaterEqualThan(columnExpression, t)
|
||||
}
|
||||
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
|
||||
timestamps, ok := v.extractTwoTimestampValues(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if operation == qbtypesv5.FilterOperatorNotBetween {
|
||||
return v.Sb.NotBetween(columnExpression, timestamps[0], timestamps[1])
|
||||
}
|
||||
return v.Sb.Between(columnExpression, timestamps[0], timestamps[1])
|
||||
}
|
||||
v.AddError("operator %s on timestamp is not implemented", OperationName(operation))
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *Visitor) BuildBoolComparison(ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, columnExpression string) string {
|
||||
value, ok := v.extractSingleBoolValue(ctx)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if operation == qbtypesv5.FilterOperatorNotEqual {
|
||||
return v.Sb.NotEqual(columnExpression, value)
|
||||
}
|
||||
return v.Sb.Equal(columnExpression, value)
|
||||
}
|
||||
|
||||
// BuildFreeTextContains COALESCEs the column so NOT (...) does not go NULL and drop rows where it is absent.
|
||||
func (v *Visitor) BuildFreeTextContains(sb *sqlbuilder.SelectBuilder, columnExpression, value string) string {
|
||||
lowerColumn := string(v.Formatter.LowerExpression(fmt.Sprintf("COALESCE(%s, '')", columnExpression)))
|
||||
pattern := fmt.Sprintf("%%%s%%", v.Formatter.EscapeLikePattern(value))
|
||||
return fmt.Sprintf("%s LIKE LOWER(%s) ESCAPE '\\'", lowerColumn, sb.Var(pattern))
|
||||
}
|
||||
|
||||
// A pattern ending in an unescaped backslash never matches on sqlite and errors on Postgres.
|
||||
func endsWithDanglingEscape(value string) bool {
|
||||
trailing := len(value) - len(strings.TrimRight(value, `\`))
|
||||
return trailing%2 == 1
|
||||
}
|
||||
|
||||
// ─── value extraction helpers ────────────────────────────────────────────────
|
||||
|
||||
func (v *Visitor) AddError(format string, arguments ...any) {
|
||||
v.errors = append(v.errors, fmt.Sprintf(format, arguments...))
|
||||
}
|
||||
|
||||
func (v *Visitor) ExtractSingleStringValue(ctx *grammar.ComparisonContext, keyForError string) (string, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.AddError("expected exactly one value for %q", keyForError)
|
||||
return "", false
|
||||
}
|
||||
return v.extractStringValue(values[0], keyForError)
|
||||
}
|
||||
|
||||
func (v *Visitor) ExtractStringValueList(ctx *grammar.ComparisonContext, keyForError string) ([]string, bool) {
|
||||
var valuesCtx []grammar.IValueContext
|
||||
switch {
|
||||
case ctx.InClause() != nil:
|
||||
inClause := ctx.InClause()
|
||||
if inClause.ValueList() != nil {
|
||||
valuesCtx = inClause.ValueList().AllValue()
|
||||
} else {
|
||||
valuesCtx = []grammar.IValueContext{inClause.Value()}
|
||||
}
|
||||
case ctx.NotInClause() != nil:
|
||||
notInClause := ctx.NotInClause()
|
||||
if notInClause.ValueList() != nil {
|
||||
valuesCtx = notInClause.ValueList().AllValue()
|
||||
} else {
|
||||
valuesCtx = []grammar.IValueContext{notInClause.Value()}
|
||||
}
|
||||
default:
|
||||
v.AddError("IN clause is missing for %q", keyForError)
|
||||
return nil, false
|
||||
}
|
||||
if len(valuesCtx) == 0 {
|
||||
v.AddError("IN list for %q is empty", keyForError)
|
||||
return nil, false
|
||||
}
|
||||
out := make([]string, 0, len(valuesCtx))
|
||||
for _, valueContext := range valuesCtx {
|
||||
s, ok := v.extractStringValue(valueContext, keyForError)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func (v *Visitor) extractSingleBoolValue(ctx *grammar.ComparisonContext) (bool, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.AddError("expected a single boolean (true/false)")
|
||||
return false, false
|
||||
}
|
||||
return v.extractBoolValue(values[0])
|
||||
}
|
||||
|
||||
func (v *Visitor) extractSingleTimestampValue(ctx *grammar.ComparisonContext) (time.Time, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 1 {
|
||||
v.AddError("expected a single RFC3339 timestamp")
|
||||
return time.Time{}, false
|
||||
}
|
||||
return v.extractTimestampValue(values[0])
|
||||
}
|
||||
|
||||
func (v *Visitor) extractTwoTimestampValues(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
|
||||
values := ctx.AllValue()
|
||||
if len(values) != 2 {
|
||||
v.AddError("BETWEEN expects two RFC3339 timestamps")
|
||||
return [2]time.Time{}, false
|
||||
}
|
||||
first, ok1 := v.extractTimestampValue(values[0])
|
||||
second, ok2 := v.extractTimestampValue(values[1])
|
||||
if !ok1 || !ok2 {
|
||||
return [2]time.Time{}, false
|
||||
}
|
||||
return [2]time.Time{first, second}, true
|
||||
}
|
||||
|
||||
func (v *Visitor) extractStringValue(ctx grammar.IValueContext, keyForError string) (string, bool) {
|
||||
if ctx.QUOTED_TEXT() != nil {
|
||||
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
|
||||
}
|
||||
if ctx.KEY() != nil {
|
||||
return ctx.KEY().GetText(), true
|
||||
}
|
||||
v.AddError("expected a string value for %q, got %q", keyForError, ctx.GetText())
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (v *Visitor) extractBoolValue(ctx grammar.IValueContext) (bool, bool) {
|
||||
if ctx.BOOL() == nil {
|
||||
v.AddError("expected a boolean (true/false), got %q", ctx.GetText())
|
||||
return false, false
|
||||
}
|
||||
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
|
||||
}
|
||||
|
||||
func (v *Visitor) extractTimestampValue(ctx grammar.IValueContext) (time.Time, bool) {
|
||||
if ctx.QUOTED_TEXT() == nil {
|
||||
v.AddError("expected an RFC3339 timestamp string, got %q", ctx.GetText())
|
||||
return time.Time{}, false
|
||||
}
|
||||
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
v.AddError("invalid RFC3339 timestamp %q: %s", raw, err.Error())
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// ─── operator spelling ───────────────────────────────────────────────────────
|
||||
|
||||
// OperationName is the user-facing spelling, used only in error messages.
|
||||
func OperationName(operation qbtypesv5.FilterOperator) string {
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
return "="
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
return "!="
|
||||
case qbtypesv5.FilterOperatorLessThan:
|
||||
return "<"
|
||||
case qbtypesv5.FilterOperatorLessThanOrEq:
|
||||
return "<="
|
||||
case qbtypesv5.FilterOperatorGreaterThan:
|
||||
return ">"
|
||||
case qbtypesv5.FilterOperatorGreaterThanOrEq:
|
||||
return ">="
|
||||
case qbtypesv5.FilterOperatorBetween:
|
||||
return "BETWEEN"
|
||||
case qbtypesv5.FilterOperatorNotBetween:
|
||||
return "NOT BETWEEN"
|
||||
case qbtypesv5.FilterOperatorLike:
|
||||
return "LIKE"
|
||||
case qbtypesv5.FilterOperatorNotLike:
|
||||
return "NOT LIKE"
|
||||
case qbtypesv5.FilterOperatorILike:
|
||||
return "ILIKE"
|
||||
case qbtypesv5.FilterOperatorNotILike:
|
||||
return "NOT ILIKE"
|
||||
case qbtypesv5.FilterOperatorContains:
|
||||
return "CONTAINS"
|
||||
case qbtypesv5.FilterOperatorNotContains:
|
||||
return "NOT CONTAINS"
|
||||
case qbtypesv5.FilterOperatorRegexp:
|
||||
return "REGEXP"
|
||||
case qbtypesv5.FilterOperatorNotRegexp:
|
||||
return "NOT REGEXP"
|
||||
case qbtypesv5.FilterOperatorIn:
|
||||
return "IN"
|
||||
case qbtypesv5.FilterOperatorNotIn:
|
||||
return "NOT IN"
|
||||
case qbtypesv5.FilterOperatorExists:
|
||||
return "EXISTS"
|
||||
case qbtypesv5.FilterOperatorNotExists:
|
||||
return "NOT EXISTS"
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
func trimQuotes(s string) string {
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
s = s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
s = strings.ReplaceAll(s, `\\`, `\`)
|
||||
s = strings.ReplaceAll(s, `\'`, `'`)
|
||||
return s
|
||||
}
|
||||
29
pkg/parser/filterquery/sqlcompiler/compiler_test.go
Normal file
29
pkg/parser/filterquery/sqlcompiler/compiler_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package sqlcompiler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEndsWithDanglingEscape(t *testing.T) {
|
||||
testCases := []struct {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{value: "", want: false},
|
||||
{value: `\`, want: true},
|
||||
{value: `\\`, want: false},
|
||||
{value: `\\\`, want: true},
|
||||
{value: `abc`, want: false},
|
||||
{value: `abc\`, want: true},
|
||||
{value: `abc\\`, want: false},
|
||||
{value: `a\b\\`, want: false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.value, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, endsWithDanglingEscape(tc.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -718,8 +718,8 @@ func (m *Manager) Rules() []Rule {
|
||||
|
||||
// TriggeredAlerts returns the list of the manager's rules.
|
||||
func (m *Manager) TriggeredAlerts() []*ruletypes.NamedAlert {
|
||||
// m.mtx.RLock()
|
||||
// defer m.mtx.RUnlock()
|
||||
m.mtx.RLock()
|
||||
defer m.mtx.RUnlock()
|
||||
|
||||
namedAlerts := []*ruletypes.NamedAlert{}
|
||||
|
||||
@@ -851,6 +851,8 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
|
||||
// initiate response object
|
||||
resp := make([]*ruletypes.GettableRule, 0)
|
||||
|
||||
stateByRuleID := m.snapshotRuleStates()
|
||||
|
||||
for _, s := range storedRules {
|
||||
|
||||
ruleResponse := ruletypes.GettableRule{}
|
||||
@@ -863,11 +865,11 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
|
||||
ruleResponse.Id = s.ID.StringValue()
|
||||
|
||||
// fetch state of rule from memory
|
||||
if rm, ok := m.rules[ruleResponse.Id]; !ok {
|
||||
if state, ok := stateByRuleID[ruleResponse.Id]; !ok {
|
||||
ruleResponse.State = ruletypes.StateDisabled
|
||||
ruleResponse.Disabled = true
|
||||
} else {
|
||||
ruleResponse.State = rm.State()
|
||||
ruleResponse.State = state
|
||||
}
|
||||
ruleResponse.CreatedAt = s.CreatedAt
|
||||
ruleResponse.CreatedBy = &s.CreatedBy
|
||||
@@ -879,6 +881,84 @@ func (m *Manager) ListRuleStates(ctx context.Context) (*ruletypes.GettableRules,
|
||||
return &ruletypes.GettableRules{Rules: resp}, nil
|
||||
}
|
||||
|
||||
// ListRules' total counts what is pageable after corrupt-row drops and the states filter.
|
||||
func (m *Manager) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
states, err := params.AlertStates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stateFilter := make(map[ruletypes.AlertState]struct{}, len(states))
|
||||
for _, state := range states {
|
||||
stateFilter[state] = struct{}{}
|
||||
}
|
||||
|
||||
storedRules, err := m.ruleStore.GetStoredRulesMatching(ctx, claims.OrgID, params.Query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stateByRuleID := m.snapshotRuleStates()
|
||||
|
||||
listableRules := make([]*ruletypes.ListableRule, 0, len(storedRules))
|
||||
for _, s := range storedRules {
|
||||
gettable := ruletypes.GettableRule{}
|
||||
if err := json.Unmarshal([]byte(s.Data), &gettable); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal rule from db", slog.String("rule.id", s.ID.StringValue()), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
gettable.Id = s.ID.StringValue()
|
||||
if state, ok := stateByRuleID[gettable.Id]; ok {
|
||||
gettable.State = state
|
||||
} else {
|
||||
gettable.State = ruletypes.StateDisabled
|
||||
gettable.Disabled = true
|
||||
}
|
||||
if len(stateFilter) > 0 {
|
||||
if _, ok := stateFilter[gettable.State]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
gettable.CreatedAt = s.CreatedAt
|
||||
gettable.CreatedBy = &s.CreatedBy
|
||||
gettable.UpdatedAt = s.UpdatedAt
|
||||
gettable.UpdatedBy = &s.UpdatedBy
|
||||
listableRules = append(listableRules, ruletypes.NewListableRule(&gettable))
|
||||
}
|
||||
|
||||
total := int64(len(listableRules))
|
||||
ruletypes.SortListableRules(listableRules, params.Sort, params.Order)
|
||||
|
||||
start := min(params.Offset, len(listableRules))
|
||||
end := min(start+params.Limit, len(listableRules))
|
||||
currentPageRules := listableRules[start:end]
|
||||
|
||||
rawLabels, err := m.ruleStore.GetStoredRuleLabels(ctx, claims.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labelPairs := ruletypes.NewLabelPairsFromRawJSON(rawLabels, ruletypes.MaxListLabelPairs)
|
||||
|
||||
return ruletypes.NewListableRules(currentPageRules, total, labelPairs), nil
|
||||
}
|
||||
|
||||
func (m *Manager) snapshotRuleStates() map[string]ruletypes.AlertState {
|
||||
m.mtx.RLock()
|
||||
defer m.mtx.RUnlock()
|
||||
|
||||
states := make(map[string]ruletypes.AlertState, len(m.rules))
|
||||
for id, rule := range m.rules {
|
||||
states[id] = rule.State()
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
@@ -899,7 +979,10 @@ func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.Getta
|
||||
}
|
||||
r.Id = id.StringValue()
|
||||
// fetch state of rule from memory
|
||||
if rm, ok := m.rules[r.Id]; !ok {
|
||||
m.mtx.RLock()
|
||||
rm, ok := m.rules[r.Id]
|
||||
m.mtx.RUnlock()
|
||||
if !ok {
|
||||
r.State = ruletypes.StateDisabled
|
||||
r.Disabled = true
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import "net/http"
|
||||
|
||||
type Handler interface {
|
||||
ListRules(http.ResponseWriter, *http.Request)
|
||||
ListRulesV3(http.ResponseWriter, *http.Request)
|
||||
GetRuleByID(http.ResponseWriter, *http.Request)
|
||||
CreateRule(http.ResponseWriter, *http.Request)
|
||||
UpdateRuleByID(http.ResponseWriter, *http.Request)
|
||||
@@ -11,6 +12,11 @@ type Handler interface {
|
||||
PatchRuleByID(http.ResponseWriter, *http.Request)
|
||||
TestRule(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListRuleViews(http.ResponseWriter, *http.Request)
|
||||
CreateRuleView(http.ResponseWriter, *http.Request)
|
||||
UpdateRuleView(http.ResponseWriter, *http.Request)
|
||||
DeleteRuleView(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
|
||||
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)
|
||||
CreateDowntimeSchedule(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -17,6 +17,9 @@ type Ruler interface {
|
||||
// ListRuleStates returns all rules with their current evaluation state.
|
||||
ListRuleStates(ctx context.Context) (*ruletypes.GettableRules, error)
|
||||
|
||||
// ListRules returns a filtered, sorted page of rules with state, plus label pairs and reserved filter keys.
|
||||
ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error)
|
||||
|
||||
// GetRule returns a single rule by ID.
|
||||
GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
|
||||
|
||||
@@ -46,4 +49,9 @@ type Ruler interface {
|
||||
// TODO: expose downtime CRUD as methods on Ruler directly instead of leaking the
|
||||
// store interface. The handler should not call store methods directly.
|
||||
MaintenanceStore() alertmanagertypes.MaintenanceStore
|
||||
|
||||
CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.RuleView, error)
|
||||
ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error)
|
||||
UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.RuleView, error)
|
||||
DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
}
|
||||
|
||||
@@ -64,6 +64,16 @@ func (m *MockSQLRuleStore) GetStoredRules(ctx context.Context, orgID string) ([]
|
||||
return m.ruleStore.GetStoredRules(ctx, orgID)
|
||||
}
|
||||
|
||||
// GetStoredRulesMatching implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
|
||||
func (m *MockSQLRuleStore) GetStoredRulesMatching(ctx context.Context, orgID string, query string) ([]*ruletypes.StorableRule, error) {
|
||||
return m.ruleStore.GetStoredRulesMatching(ctx, orgID, query)
|
||||
}
|
||||
|
||||
// GetStoredRuleLabels implements ruletypes.RuleStore - delegates to underlying ruleStore to trigger SQL.
|
||||
func (m *MockSQLRuleStore) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
|
||||
return m.ruleStore.GetStoredRuleLabels(ctx, orgID)
|
||||
}
|
||||
|
||||
// GetStoredRulesByMetricName implements ruletypes.RuleStore - delegates to underlying ruleStore.
|
||||
func (m *MockSQLRuleStore) GetStoredRulesByMetricName(ctx context.Context, orgID string, metricName string) ([]ruletypes.RuleAlert, error) {
|
||||
return m.ruleStore.GetStoredRulesByMetricName(ctx, orgID, metricName)
|
||||
|
||||
20
pkg/ruler/rulestore/sqlrulestore/filterquery.go
Normal file
20
pkg/ruler/rulestore/sqlrulestore/filterquery.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package sqlrulestore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
// Compile wraps compiler errors in the rules list filter error code.
|
||||
func Compile(query string, formatter sqlstore.SQLFormatter) (*sqlcompiler.Compiled, error) {
|
||||
compiled, errs := sqlcompiler.Compile(query, formatter, ruleFieldResolver{})
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.NewInvalidInputf(ruletypes.ErrCodeRuleListFilterInvalid,
|
||||
"invalid filter query: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return compiled, nil
|
||||
}
|
||||
168
pkg/ruler/rulestore/sqlrulestore/filterquery_resolver.go
Normal file
168
pkg/ruler/rulestore/sqlrulestore/filterquery_resolver.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package sqlrulestore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
|
||||
"github.com/SigNoz/signoz/pkg/parser/filterquery/sqlcompiler"
|
||||
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
const (
|
||||
ruleDataColumn = "rule.data"
|
||||
ruleLabelsField = "labels"
|
||||
nameJSONPath = "$.alert"
|
||||
descriptionPath = "$.description"
|
||||
labelsJSONPath = "$.labels"
|
||||
alertTypePath = "$.alertType"
|
||||
ruleTypePath = "$.ruleType"
|
||||
)
|
||||
|
||||
// ruleFieldResolver maps rule list DSL keys; label keys are case-sensitive and unknown keys are rejected.
|
||||
type ruleFieldResolver struct{}
|
||||
|
||||
func (r ruleFieldResolver) ResolveComparison(v *sqlcompiler.Visitor, rawKey string, operation qbtypesv5.FilterOperator, ctx *grammar.ComparisonContext) string {
|
||||
key := strings.ToLower(rawKey)
|
||||
|
||||
if allowedOperations, isReserved := ruletypes.ReservedOps[ruletypes.DSLKey(key)]; isReserved {
|
||||
return r.resolveReservedKey(v, ctx, operation, ruletypes.DSLKey(key), allowedOperations)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(key, ruletypes.DSLLabelsKeyPrefix) {
|
||||
labelKey := rawKey[len(ruletypes.DSLLabelsKeyPrefix):]
|
||||
if labelKey == "" {
|
||||
v.AddError("labels filter is missing a key, use labels.<key>")
|
||||
return ""
|
||||
}
|
||||
if _, allowed := ruletypes.LabelsKeyOps[operation]; !allowed {
|
||||
v.AddError("operator %s is not allowed on a labels.<key> filter", sqlcompiler.OperationName(operation))
|
||||
return ""
|
||||
}
|
||||
return r.labelComparison(v, ctx, operation, labelKey)
|
||||
}
|
||||
|
||||
v.AddError("unknown filter key %q, use one of the reserved keys or labels.<key>", rawKey)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r ruleFieldResolver) resolveReservedKey(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey, allowedOperations map[qbtypesv5.FilterOperator]struct{}) string {
|
||||
if _, allowed := allowedOperations[operation]; !allowed {
|
||||
v.AddError("operator %s is not allowed for key %q", sqlcompiler.OperationName(operation), key)
|
||||
return ""
|
||||
}
|
||||
switch key {
|
||||
case ruletypes.DSLKeyName:
|
||||
columnExpression := string(v.Formatter.JSONExtractString(ruleDataColumn, nameJSONPath))
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, string(key))
|
||||
case ruletypes.DSLKeySeverity:
|
||||
// severity is an alias for labels.severity, sharing its missing-label semantics.
|
||||
return r.labelComparison(v, ctx, operation, "severity")
|
||||
case ruletypes.DSLKeyCreatedBy:
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "rule.created_by", string(key))
|
||||
case ruletypes.DSLKeyUpdatedBy:
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, "rule.updated_by", string(key))
|
||||
case ruletypes.DSLKeyCreatedAt:
|
||||
return v.BuildTimestampComparison(ctx, operation, "rule.created_at")
|
||||
case ruletypes.DSLKeyUpdatedAt:
|
||||
return v.BuildTimestampComparison(ctx, operation, "rule.updated_at")
|
||||
case ruletypes.DSLKeyAlertType:
|
||||
return r.enumComparison(v, ctx, operation, key, alertTypePath, alertTypeValues)
|
||||
case ruletypes.DSLKeyRuleType:
|
||||
return r.enumComparison(v, ctx, operation, key, ruleTypePath, ruleTypeValues)
|
||||
}
|
||||
v.AddError("no handler for reserved key %q", key)
|
||||
return ""
|
||||
}
|
||||
|
||||
// A missing label evaluates as the empty string for every value operator; EXISTS/NOT EXISTS test the raw extraction.
|
||||
func (ruleFieldResolver) labelComparison(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, labelKey string) string {
|
||||
columnExpression := string(v.Formatter.JSONExtractMapValue(ruleDataColumn, ruleLabelsField, labelKey))
|
||||
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorExists:
|
||||
return fmt.Sprintf("%s IS NOT NULL", columnExpression)
|
||||
case qbtypesv5.FilterOperatorNotExists:
|
||||
return fmt.Sprintf("%s IS NULL", columnExpression)
|
||||
}
|
||||
|
||||
keyForError := ruletypes.DSLLabelsKeyPrefix + labelKey
|
||||
columnExpression = fmt.Sprintf("COALESCE(%s, '')", columnExpression)
|
||||
return v.BuildStringOperation(v.Sb, ctx, operation, columnExpression, keyForError)
|
||||
}
|
||||
|
||||
func (ruleFieldResolver) enumComparison(v *sqlcompiler.Visitor, ctx *grammar.ComparisonContext, operation qbtypesv5.FilterOperator, key ruletypes.DSLKey, jsonPath string, allowedValues []string) string {
|
||||
columnExpression := string(v.Formatter.JSONExtractString(ruleDataColumn, jsonPath))
|
||||
|
||||
var values []string
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual:
|
||||
value, ok := v.ExtractSingleStringValue(ctx, string(key))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
values = []string{value}
|
||||
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
|
||||
list, ok := v.ExtractStringValueList(ctx, string(key))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
values = list
|
||||
default:
|
||||
v.AddError("operator %s on %q is not implemented", sqlcompiler.OperationName(operation), key)
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
if !slices.Contains(allowedValues, value) {
|
||||
v.AddError("invalid value %q for %q, expected one of: %s", value, key, strings.Join(allowedValues, ", "))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
arguments := make([]any, len(values))
|
||||
for i, s := range values {
|
||||
arguments[i] = s
|
||||
}
|
||||
switch operation {
|
||||
case qbtypesv5.FilterOperatorEqual:
|
||||
return v.Sb.Equal(columnExpression, arguments[0])
|
||||
case qbtypesv5.FilterOperatorNotEqual:
|
||||
return v.Sb.NotEqual(columnExpression, arguments[0])
|
||||
case qbtypesv5.FilterOperatorNotIn:
|
||||
return v.Sb.NotIn(columnExpression, arguments...)
|
||||
default:
|
||||
return v.Sb.In(columnExpression, arguments...)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveFreeText searches name, description and the raw labels JSON (which also matches label keys).
|
||||
func (ruleFieldResolver) ResolveFreeText(v *sqlcompiler.Visitor, value string) string {
|
||||
nameColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, nameJSONPath))
|
||||
descriptionColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, descriptionPath))
|
||||
labelsColumn := string(v.Formatter.JSONExtractString(ruleDataColumn, labelsJSONPath))
|
||||
|
||||
return v.Sb.Or(
|
||||
v.BuildFreeTextContains(v.Sb, nameColumn, value),
|
||||
v.BuildFreeTextContains(v.Sb, descriptionColumn, value),
|
||||
v.BuildFreeTextContains(v.Sb, labelsColumn, value),
|
||||
)
|
||||
}
|
||||
|
||||
var alertTypeValues = func() []string {
|
||||
values := make([]string, 0, 4)
|
||||
for _, value := range (ruletypes.AlertType("")).Enum() {
|
||||
values = append(values, string(value.(ruletypes.AlertType)))
|
||||
}
|
||||
return values
|
||||
}()
|
||||
|
||||
var ruleTypeValues = func() []string {
|
||||
values := make([]string, 0, 3)
|
||||
for _, value := range (ruletypes.RuleType{}).Enum() {
|
||||
values = append(values, value.(ruletypes.RuleType).StringValue())
|
||||
}
|
||||
return values
|
||||
}()
|
||||
407
pkg/ruler/rulestore/sqlrulestore/filterquery_test.go
Normal file
407
pkg/ruler/rulestore/sqlrulestore/filterquery_test.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package sqlrulestore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
type compileCase struct {
|
||||
subtestName string
|
||||
dslQueryToCompile string
|
||||
emptyQueryExpected bool
|
||||
expectedSQL string
|
||||
expectedArgs []any
|
||||
expectedErrShouldContain string
|
||||
}
|
||||
|
||||
func runCompileCases(t *testing.T, cases []compileCase) {
|
||||
t.Helper()
|
||||
for _, c := range cases {
|
||||
t.Run(c.subtestName, func(t *testing.T) {
|
||||
out, err := Compile(c.dslQueryToCompile, formatter(t))
|
||||
|
||||
if c.expectedErrShouldContain != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
if c.emptyQueryExpected {
|
||||
assert.True(t, out.IsEmpty())
|
||||
return
|
||||
}
|
||||
require.NotNil(t, out)
|
||||
|
||||
if c.expectedSQL != "" {
|
||||
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
|
||||
}
|
||||
if c.expectedArgs != nil {
|
||||
require.Len(t, out.Args, len(c.expectedArgs))
|
||||
for i, want := range c.expectedArgs {
|
||||
// Equal instants can differ in *Location, so compare via .Equal() instead of DeepEqual.
|
||||
if wantT, ok := want.(time.Time); ok {
|
||||
gotT, ok := out.Args[i].(time.Time)
|
||||
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
|
||||
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
|
||||
continue
|
||||
}
|
||||
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileEmpty(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{subtestName: "empty query yields nil", dslQueryToCompile: "", emptyQueryExpected: true},
|
||||
{subtestName: "whitespace query yields nil", dslQueryToCompile: " ", emptyQueryExpected: true},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileName(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "name equals",
|
||||
dslQueryToCompile: "name = 'payment latency'",
|
||||
expectedSQL: `json_extract("rule"."data", '$.alert') = ?`,
|
||||
expectedArgs: []any{"payment latency"},
|
||||
},
|
||||
{
|
||||
subtestName: "name contains escapes wildcards",
|
||||
dslQueryToCompile: "name CONTAINS '50%'",
|
||||
expectedSQL: `json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\'`,
|
||||
expectedArgs: []any{`%50\%%`},
|
||||
},
|
||||
{
|
||||
subtestName: "name ilike",
|
||||
dslQueryToCompile: "name ILIKE 'Prod%'",
|
||||
expectedSQL: `lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\'`,
|
||||
expectedArgs: []any{"Prod%"},
|
||||
},
|
||||
{
|
||||
subtestName: "name in list",
|
||||
dslQueryToCompile: "name IN ['a', 'b']",
|
||||
expectedSQL: `json_extract("rule"."data", '$.alert') IN (?, ?)`,
|
||||
expectedArgs: []any{"a", "b"},
|
||||
},
|
||||
{
|
||||
subtestName: "range operator rejected on name",
|
||||
dslQueryToCompile: "name > 'x'",
|
||||
expectedErrShouldContain: `operator > is not allowed for key "name"`,
|
||||
},
|
||||
{
|
||||
subtestName: "regexp rejected on name",
|
||||
dslQueryToCompile: "name REGEXP 'x.*'",
|
||||
expectedErrShouldContain: `operator REGEXP is not allowed for key "name"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileSeverityAndLabels(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "severity equals targets labels map",
|
||||
dslQueryToCompile: "severity = 'critical'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?`,
|
||||
expectedArgs: []any{"critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "severity negation treats a missing label as empty string",
|
||||
dslQueryToCompile: "severity != 'critical'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
|
||||
expectedArgs: []any{"critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "severity != empty string excludes rules without severity",
|
||||
dslQueryToCompile: "severity != ''",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') <> ?`,
|
||||
expectedArgs: []any{""},
|
||||
},
|
||||
{
|
||||
subtestName: "severity exists through the alias",
|
||||
dslQueryToCompile: "severity EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "severity not exists through the alias",
|
||||
dslQueryToCompile: "severity NOT EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."severity"') IS NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "label equals",
|
||||
dslQueryToCompile: "labels.team = 'infra'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ?`,
|
||||
expectedArgs: []any{"infra"},
|
||||
},
|
||||
{
|
||||
subtestName: "dotted label key is one map entry",
|
||||
dslQueryToCompile: "labels.k8s.cluster = 'prod-1'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."k8s.cluster"'), '') = ?`,
|
||||
expectedArgs: []any{"prod-1"},
|
||||
},
|
||||
{
|
||||
subtestName: "label key keeps its case",
|
||||
dslQueryToCompile: "labels.Team = 'infra'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."Team"'), '') = ?`,
|
||||
expectedArgs: []any{"infra"},
|
||||
},
|
||||
{
|
||||
subtestName: "label exists",
|
||||
dslQueryToCompile: "labels.team EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NOT NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "label not exists",
|
||||
dslQueryToCompile: "labels.team NOT EXISTS",
|
||||
expectedSQL: `json_extract("rule"."data", '$.labels."team"') IS NULL`,
|
||||
},
|
||||
{
|
||||
subtestName: "label not contains includes label-less rules",
|
||||
dslQueryToCompile: "labels.team NOT CONTAINS 'infra'",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT LIKE ? ESCAPE '\'`,
|
||||
expectedArgs: []any{"%infra%"},
|
||||
},
|
||||
{
|
||||
subtestName: "label not in includes label-less rules",
|
||||
dslQueryToCompile: "labels.team NOT IN ['a', 'b']",
|
||||
expectedSQL: `COALESCE(json_extract("rule"."data", '$.labels."team"'), '') NOT IN (?, ?)`,
|
||||
expectedArgs: []any{"a", "b"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileEnums(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "alert_type equals",
|
||||
dslQueryToCompile: "alert_type = 'LOGS_BASED_ALERT'",
|
||||
expectedSQL: `json_extract("rule"."data", '$.alertType') = ?`,
|
||||
expectedArgs: []any{"LOGS_BASED_ALERT"},
|
||||
},
|
||||
{
|
||||
subtestName: "rule_type in list",
|
||||
dslQueryToCompile: "rule_type IN ['threshold_rule', 'promql_rule']",
|
||||
expectedSQL: `json_extract("rule"."data", '$.ruleType') IN (?, ?)`,
|
||||
expectedArgs: []any{"threshold_rule", "promql_rule"},
|
||||
},
|
||||
{
|
||||
subtestName: "invalid alert_type value rejected",
|
||||
dslQueryToCompile: "alert_type = 'bogus'",
|
||||
expectedErrShouldContain: `invalid value "bogus" for "alert_type"`,
|
||||
},
|
||||
{
|
||||
subtestName: "contains rejected on rule_type",
|
||||
dslQueryToCompile: "rule_type CONTAINS 'thresh'",
|
||||
expectedErrShouldContain: `operator CONTAINS is not allowed for key "rule_type"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileAuditColumns(t *testing.T) {
|
||||
createdAt, err := time.Parse(time.RFC3339, "2026-01-02T15:04:05Z")
|
||||
require.NoError(t, err)
|
||||
updatedFrom, err := time.Parse(time.RFC3339, "2026-02-01T00:00:00Z")
|
||||
require.NoError(t, err)
|
||||
updatedTo, err := time.Parse(time.RFC3339, "2026-03-01T00:00:00Z")
|
||||
require.NoError(t, err)
|
||||
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "created_by equals",
|
||||
dslQueryToCompile: "created_by = 'nikhil@signoz.io'",
|
||||
expectedSQL: `rule.created_by = ?`,
|
||||
expectedArgs: []any{"nikhil@signoz.io"},
|
||||
},
|
||||
{
|
||||
subtestName: "created_at range",
|
||||
dslQueryToCompile: "created_at >= '2026-01-02T15:04:05Z'",
|
||||
expectedSQL: `rule.created_at >= ?`,
|
||||
expectedArgs: []any{createdAt},
|
||||
},
|
||||
{
|
||||
subtestName: "updated_at between",
|
||||
dslQueryToCompile: "updated_at BETWEEN '2026-02-01T00:00:00Z' AND '2026-03-01T00:00:00Z'",
|
||||
expectedSQL: `rule.updated_at BETWEEN ? AND ?`,
|
||||
expectedArgs: []any{updatedFrom, updatedTo},
|
||||
},
|
||||
{
|
||||
subtestName: "non-timestamp rejected on created_at",
|
||||
dslQueryToCompile: "created_at >= 'yesterday'",
|
||||
expectedErrShouldContain: "invalid RFC3339 timestamp",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileFreeText(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "bare word searches name, description and labels",
|
||||
dslQueryToCompile: "payment",
|
||||
expectedSQL: `(lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\')`,
|
||||
expectedArgs: []any{"%payment%", "%payment%", "%payment%"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileComposition(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "and of label and column",
|
||||
dslQueryToCompile: "labels.team = 'infra' AND created_by = 'x'",
|
||||
expectedSQL: `(COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? AND rule.created_by = ?)`,
|
||||
expectedArgs: []any{"infra", "x"},
|
||||
},
|
||||
{
|
||||
subtestName: "not wraps the inner predicate",
|
||||
dslQueryToCompile: "NOT (name = 'x')",
|
||||
expectedSQL: `NOT (json_extract("rule"."data", '$.alert') = ?)`,
|
||||
expectedArgs: []any{"x"},
|
||||
},
|
||||
{
|
||||
subtestName: "or of name and severity",
|
||||
dslQueryToCompile: "name CONTAINS 'pay' OR severity = 'critical'",
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' OR COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ?)`,
|
||||
expectedArgs: []any{"%pay%", "critical"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileComplexExamples(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "name CONTAINS + label = + severity IN + created_by !=",
|
||||
dslQueryToCompile: `name CONTAINS 'latency' AND labels.team = 'payments' ` +
|
||||
`AND severity IN ['critical', 'error'] AND created_by != 'ops@signoz.io'`,
|
||||
expectedSQL: `(json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\' ` +
|
||||
`AND COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
|
||||
`AND COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') IN (?, ?) ` +
|
||||
`AND rule.created_by <> ?)`,
|
||||
expectedArgs: []any{"%latency%", "payments", "critical", "error", "ops@signoz.io"},
|
||||
},
|
||||
{
|
||||
subtestName: "nested OR / AND with parens",
|
||||
dslQueryToCompile: `(labels.env IN ['prod', 'staging'] OR name LIKE '%prod%') ` +
|
||||
`AND (severity = 'critical' OR labels.team EXISTS)`,
|
||||
expectedSQL: `((COALESCE(json_extract("rule"."data", '$.labels."env"'), '') IN (?, ?) ` +
|
||||
`OR json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\') ` +
|
||||
`AND (COALESCE(json_extract("rule"."data", '$.labels."severity"'), '') = ? ` +
|
||||
`OR json_extract("rule"."data", '$.labels."team"') IS NOT NULL))`,
|
||||
expectedArgs: []any{"prod", "staging", "%prod%", "critical"},
|
||||
},
|
||||
{
|
||||
subtestName: "NOT over a group ANDed with an enum",
|
||||
dslQueryToCompile: `NOT (labels.team = 'infra' OR name CONTAINS 'cpu') AND alert_type = 'METRIC_BASED_ALERT'`,
|
||||
expectedSQL: `(NOT ((COALESCE(json_extract("rule"."data", '$.labels."team"'), '') = ? ` +
|
||||
`OR json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\')) ` +
|
||||
`AND json_extract("rule"."data", '$.alertType') = ?)`,
|
||||
expectedArgs: []any{"infra", "%cpu%", "METRIC_BASED_ALERT"},
|
||||
},
|
||||
{
|
||||
subtestName: "free text with three-level nesting and a timestamp",
|
||||
dslQueryToCompile: `prod AND (name ILIKE '%pay%' ` +
|
||||
`OR (labels.team != 'infra' AND updated_at > '2026-01-02T15:04:05Z'))`,
|
||||
expectedSQL: `((lower(COALESCE(json_extract("rule"."data", '$.alert'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.description'), '')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR lower(COALESCE(json_extract("rule"."data", '$.labels'), '')) LIKE LOWER(?) ESCAPE '\') ` +
|
||||
`AND (lower(json_extract("rule"."data", '$.alert')) LIKE LOWER(?) ESCAPE '\' ` +
|
||||
`OR (COALESCE(json_extract("rule"."data", '$.labels."team"'), '') <> ? AND rule.updated_at > ?)))`,
|
||||
expectedArgs: []any{"%prod%", "%prod%", "%prod%", "%pay%", "infra",
|
||||
time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC)},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileErrors(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "unknown key rejected instead of matching nothing",
|
||||
dslQueryToCompile: "team = 'infra'",
|
||||
expectedErrShouldContain: `unknown filter key "team"`,
|
||||
},
|
||||
{
|
||||
subtestName: "state is not a DSL key",
|
||||
dslQueryToCompile: "state = 'firing'",
|
||||
expectedErrShouldContain: `unknown filter key "state"`,
|
||||
},
|
||||
{
|
||||
subtestName: "syntax error surfaces position",
|
||||
dslQueryToCompile: "created_by ==== (((",
|
||||
expectedErrShouldContain: "syntax error",
|
||||
},
|
||||
{
|
||||
subtestName: "like pattern with dangling escape rejected",
|
||||
dslQueryToCompile: `name LIKE 'prod\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
{
|
||||
subtestName: "ilike pattern with dangling escape rejected",
|
||||
dslQueryToCompile: `name ILIKE '%\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
{
|
||||
subtestName: "label like pattern with dangling escape rejected",
|
||||
dslQueryToCompile: `labels.team NOT LIKE 'infra\\'`,
|
||||
expectedErrShouldContain: "must not end with an unescaped backslash",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompileTrailingLiteralBackslash(t *testing.T) {
|
||||
runCompileCases(t, []compileCase{
|
||||
{
|
||||
subtestName: "escaped trailing backslash compiles",
|
||||
dslQueryToCompile: `name LIKE '%\\\\'`,
|
||||
expectedSQL: `json_extract("rule"."data", '$.alert') LIKE ? ESCAPE '\'`,
|
||||
expectedArgs: []any{`%\\`},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Guards that every ruletypes.ReservedOps key has a case in resolveReservedKey.
|
||||
func TestCompileReservedKeysAllHandled(t *testing.T) {
|
||||
sampleQueries := map[ruletypes.DSLKey]string{
|
||||
ruletypes.DSLKeyName: "name = 'x'",
|
||||
ruletypes.DSLKeySeverity: "severity = 'critical'",
|
||||
ruletypes.DSLKeyCreatedBy: "created_by = 'x'",
|
||||
ruletypes.DSLKeyUpdatedBy: "updated_by = 'x'",
|
||||
ruletypes.DSLKeyCreatedAt: "created_at >= '2026-01-02T15:04:05Z'",
|
||||
ruletypes.DSLKeyUpdatedAt: "updated_at >= '2026-01-02T15:04:05Z'",
|
||||
ruletypes.DSLKeyAlertType: "alert_type = 'METRIC_BASED_ALERT'",
|
||||
ruletypes.DSLKeyRuleType: "rule_type = 'threshold_rule'",
|
||||
}
|
||||
|
||||
for key := range ruletypes.ReservedOps {
|
||||
query, ok := sampleQueries[key]
|
||||
require.True(t, ok, "no sample query for reserved key %q, add one", key)
|
||||
|
||||
out, err := Compile(query, formatter(t))
|
||||
require.NoError(t, err, "reserved key %q failed to compile", key)
|
||||
assert.False(t, out.IsEmpty(), "reserved key %q compiled to empty SQL", key)
|
||||
}
|
||||
}
|
||||
|
||||
func formatter(t *testing.T) sqlstore.SQLFormatter {
|
||||
t.Helper()
|
||||
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
|
||||
return p.Formatter()
|
||||
}
|
||||
|
||||
func normalizeSQL(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
s = strings.ReplaceAll(s, "( ", "(")
|
||||
s = strings.ReplaceAll(s, " )", ")")
|
||||
return s
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package sqlrulestore
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
@@ -89,6 +90,46 @@ func (r *rule) DeleteRule(ctx context.Context, orgID valuer.UUID, id valuer.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rule) GetStoredRulesMatching(ctx context.Context, orgID string, query string) ([]*ruletypes.StorableRule, error) {
|
||||
compiled, err := Compile(query, r.sqlstore.Formatter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]*ruletypes.StorableRule, 0)
|
||||
q := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&rules).
|
||||
Where("org_id = ?", orgID)
|
||||
if !compiled.IsEmpty() {
|
||||
q = q.Where(compiled.SQL, compiled.Args...)
|
||||
}
|
||||
if err := q.Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (r *rule) GetStoredRuleLabels(ctx context.Context, orgID string) ([]string, error) {
|
||||
labelsExpression := string(r.sqlstore.Formatter().JSONExtractString("rule.data", "$.labels"))
|
||||
|
||||
labels := make([]string, 0)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model((*ruletypes.StorableRule)(nil)).
|
||||
ColumnExpr(fmt.Sprintf("COALESCE(%s, '')", labelsExpression)).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx, &labels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (r *rule) GetStoredRules(ctx context.Context, orgID string) ([]*ruletypes.StorableRule, error) {
|
||||
rules := make([]*ruletypes.StorableRule, 0)
|
||||
err := r.sqlstore.
|
||||
|
||||
93
pkg/ruler/rulestore/sqlrulestore/rule_view.go
Normal file
93
pkg/ruler/rulestore/sqlrulestore/rule_view.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlrulestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func (r *rule) CreateRuleView(ctx context.Context, view *ruletypes.RuleView) error {
|
||||
_, err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(view).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "rule view with id %s already exists", view.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rule) GetRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*ruletypes.RuleView, error) {
|
||||
view := new(ruletypes.RuleView)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(view).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, r.sqlstore.WrapNotFoundErrf(err, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (r *rule) ListRuleViews(ctx context.Context, orgID valuer.UUID) ([]*ruletypes.RuleView, error) {
|
||||
views := make([]*ruletypes.RuleView, 0)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(&views).
|
||||
Where("org_id = ?", orgID).
|
||||
OrderExpr("updated_at DESC").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't list rule views")
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (r *rule) UpdateRuleView(ctx context.Context, view *ruletypes.RuleView) error {
|
||||
res, err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(view).
|
||||
WherePK().
|
||||
Where("org_id = ?", view.OrgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't update rule view")
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view update result")
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", view.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *rule) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
res, err := r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(ruletypes.RuleView)).
|
||||
Where("id = ?", id).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't delete rule view")
|
||||
}
|
||||
rows, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "couldn't read rule view delete result")
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, ruletypes.ErrCodeRuleViewNotFound, "rule view with id %s doesn't exist", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -43,6 +43,29 @@ func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := new(ruletypes.ListRulesParams)
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), params); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
listableRules, err := handler.ruler.ListRules(ctx, params)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, listableRules)
|
||||
}
|
||||
|
||||
func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -322,3 +345,122 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListRuleViews(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
views, err := handler.ruler.ListRuleViews(ctx, orgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, views)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateRuleView(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var postable ruletypes.PostableRuleView
|
||||
if err := binding.JSON.BindBody(req.Body, &postable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.ruler.CreateRuleView(ctx, orgID, postable)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, view)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateRuleView(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
var updatable ruletypes.UpdatableRuleView
|
||||
if err := binding.JSON.BindBody(req.Body, &updatable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.ruler.UpdateRuleView(ctx, orgID, id, updatable)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteRuleView(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.ruler.DeleteRuleView(ctx, orgID, id); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ func (provider *provider) ListRuleStates(ctx context.Context) (*ruletypes.Gettab
|
||||
return provider.manager.ListRuleStates(ctx)
|
||||
}
|
||||
|
||||
func (provider *provider) ListRules(ctx context.Context, params *ruletypes.ListRulesParams) (*ruletypes.ListableRules, error) {
|
||||
return provider.manager.ListRules(ctx, params)
|
||||
}
|
||||
|
||||
func (provider *provider) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
|
||||
return provider.manager.GetRule(ctx, id)
|
||||
}
|
||||
@@ -143,3 +147,41 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
|
||||
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
return provider.manager.MaintenanceStore()
|
||||
}
|
||||
|
||||
func (provider *provider) CreateRuleView(ctx context.Context, orgID valuer.UUID, postable ruletypes.PostableRuleView) (*ruletypes.RuleView, error) {
|
||||
if err := postable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := postable.NewRuleView(orgID)
|
||||
if err := provider.ruleStore.CreateRuleView(ctx, view); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (provider *provider) ListRuleViews(ctx context.Context, orgID valuer.UUID) (*ruletypes.ListableRuleViews, error) {
|
||||
views, err := provider.ruleStore.ListRuleViews(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ruletypes.ListableRuleViews{Views: views}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) UpdateRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable ruletypes.UpdatableRuleView) (*ruletypes.RuleView, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view, err := provider.ruleStore.GetRuleView(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view.Update(updatable)
|
||||
if err := provider.ruleStore.UpdateRuleView(ctx, view); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (provider *provider) DeleteRuleView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
|
||||
return provider.ruleStore.DeleteRuleView(ctx, orgID, id)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -62,10 +61,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
require.NoError(t, err)
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -45,6 +45,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
@@ -123,10 +124,9 @@ func NewModules(
|
||||
fl flagger.Flagger,
|
||||
tagModule tag.Module,
|
||||
metricReductionRule metricreductionrule.Module,
|
||||
spanMapper spanmapper.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard, spanMapper)
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
@@ -162,7 +162,7 @@ func NewModules(
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -69,11 +68,7 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
spanMapperRegistry, err := implspanmapper.NewSystemGroupRegistry()
|
||||
require.NoError(t, err)
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), flagger, spanMapperRegistry, providerSettings)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), spanMapperModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -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"
|
||||
@@ -101,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{})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user