Compare commits

..

45 Commits

Author SHA1 Message Date
Nikhil Mantri
339a10217e Merge branch 'feat/alerts_listing_page_revamp' into feat/alert_rule_views 2026-09-10 18:57:09 +05:30
Nikhil Mantri
cd0eb62734 Merge branch 'main' into feat/alerts_listing_page_revamp 2026-09-10 18:56:27 +05:30
Nikhil Mantri
ad68a1b991 refactor: extract shared list filter SQL compiler from the dashboards visitor (#12806)
#### Description

- Extracts the dashboards list filter visitor's generic core into a
shared package, `pkg/parser/filterquery/sqlcompiler`: boolean
composition, operator dispatch, value extraction, the LIKE family
builders and the `Compiled` output type, behind a small per-feature
`FieldResolver`.
- Dashboards keeps only a key-policy resolver (`listfilter_resolver.go`)
and an error-code wrap; the old 631-line visitor is deleted. Emitted SQL
is unchanged, pinned by the existing exact-SQL unit suite.
- Scope note in the package doc: this compiler serves list pages over
the relational sql DB. Telemetry queries stay on querybuilder's
ClickHouse visitor.
- Also fixes a pre-existing bug shared with the copy: a `LIKE`/`ILIKE`
pattern ending in an unescaped backslash never matches on sqlite and
aborts the query on Postgres (a data-dependent 500); it is now rejected
as a 400, with a unit case.
- Split out of #12780 per review so the commoning-out merges first; the
alert rules list API consumes this next (#12780 will be rebased onto
this branch). Part of SigNoz/pulse-pod#226.
2026-09-10 13:09:05 +00:00
Abhi kumar
f9294367b1 fix(dashboard): honour "open in new tab" on v2 context links (#12836)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- The panel editor persisted the "Open in new tab" toggle as
`targetBlank`, but the drilldown menu dropped the field while resolving
links and always called `openInNewTab`.
- `resolvePanelContextLinks` now carries `targetBlank` through
(defaulting to `true` when unset, matching the editor default), and the
menu branches on it.
- Added `openInSameTab` to `utils/navigation` so internal paths still
get the base-path prefix and external URLs pass through unchanged.
- Auto-generated data links ("View Trace Details") keep opening in a new
tab — they have no toggle.


Closes https://github.com/SigNoz/signoz/issues/12810
2026-09-10 11:27:49 +00:00
nikhilmantri0902
a667007991 Merge remote-tracking branch 'origin/feat/alerts_listing_page_revamp' into feat/alert_rule_views 2026-09-10 16:04:32 +05:30
nikhilmantri0902
208fc1e8ec Merge remote-tracking branch 'origin/feat/common_out_visitors_and_sql_parser' into feat/alerts_listing_page_revamp 2026-09-10 15:58:38 +05:30
nikhilmantri0902
54eb67392a refactor(filterquery): order the compiler main flow first
Visit methods follow Compile; builders, extractors and operator
spelling sit below with main's section separators.
2026-09-10 15:53:07 +05:30
nikhilmantri0902
0e0d97c16f refactor(filterquery): address review nits on the compiler
Verb-first helper names (BuildStringOperation etc., ResolveFreeText),
Sb and Formatter as exported fields instead of getters, and tests for
the dangling-backslash rejection.
2026-09-10 15:43:58 +05:30
Pandey
1419e03ec1 feat(apiserver): add tls support to http server (#12830)
#### Description

- Adds optional TLS to `pkg/http/server`, exposed under `apiserver.tls`:
`enabled`, `cert_file`, `key_file`, `min_version` ("1.2" or "1.3").
- When enabled, the apiserver loads the key pair at startup and serves
HTTPS via `ListenAndServeTLS`; disabled by default, no behavior change
otherwise.
- Env: `SIGNOZ_APISERVER_TLS_ENABLED`,
`SIGNOZ_APISERVER_TLS_CERT__FILE`, `SIGNOZ_APISERVER_TLS_KEY__FILE`,
`SIGNOZ_APISERVER_TLS_MIN__VERSION`.
- Adds `.claude/rules/go-test.md`; the new http server tests follow it.
- Part of SigNoz/platform-pod#2302 — covers the apiserver HTTP piece
only.
2026-09-10 09:39:40 +00:00
nikhilmantri0902
659f9c7ecf test(ruletypes): assert UpdatedAt strictly advances on view update 2026-09-10 13:09:54 +05:30
nikhilmantri0902
ce8442c17a chore(ruler): drop rationale comments from rule view code 2026-09-10 13:09:53 +05:30
nikhilmantri0902
7ab21678b5 test(alerts): own rule view cleanup in a fixture
The lifecycle test filters lists by its own view names instead of wiping
the org's views and asserting global counts.
2026-09-10 13:02:47 +05:30
nikhilmantri0902
951bfb66cd fix(ruletypes): never return null states on a rule view
Validate normalizes nil states to an empty slice and the field is marked
non-nullable; regenerates the openapi spec and the frontend api client.
2026-09-10 13:02:42 +05:30
Abhi kumar
1c1a5dc544 fix(query-builder): stop the panel-type field list growing on every change (#12782)
#### Description

`updateSuperSetQueryBuilderData` appended `dataSource` to the field list
with `propsRequired?.push('dataSource')`. That list is the array held
inside `panelTypeDataSourceFormValuesMap`, so the module-level table
grew by one entry **on every query-builder change**, unbounded for the
life of the page. It was harmless only because the assignment it drives
is idempotent — `set(queryItem, 'dataSource', …)` writes the same value
each time.

The field now travels on a copy. The guard stays an `if` rather than
defaulting to an empty list, because the previous optional chaining
meant a panel type outside the builder set copied *nothing at all*,
`dataSource` included — defaulting would have changed that.

Two neighbours in the same area came along, both no-ops:

- **`PANEL_TYPES_INITIAL_QUERY` deleted** — it had exactly one reference
in the repo, its own definition.
- **`PanelTypeKeys` derived** — it was a hand-written union of the
enum's key names that had fallen three members behind (`BAR`, `PIE`,
`HISTOGRAM`), and is now `keyof typeof PANEL_TYPES`.

#### Additional Information

- Nothing relied on the stale union: `useChartMutable` builds its key
array via `[].slice.call(Object.keys(PANEL_TYPES))`, which is untyped,
so all nine keys were already present at runtime and
`BAR`/`PIE`/`HISTOGRAM` resolved correctly. The union was a type-level
lie with no behavioural effect, and nothing consumes it exhaustively —
two component props and that one `.find`.
- Verified with `tsgo`, `oxlint`, and the `providers` / `lib` / `hooks`
/ `WidgetCard` / Logs+Traces+Metrics explorer / `EmptyLogsSearch` /
`DashboardPage` suites: **303 suites, 2442 tests**. The jest run needs
`--runInBand` to be trustworthy on a loaded machine.
- Merge-order note: #12742 adds a `TEXT` entry to
`PANEL_TYPES_INITIAL_QUERY`. Whichever of the two merges second will see
that hunk conflict — resolution is to keep the deletion here, which
makes #12742 one entry smaller.
2026-09-10 07:16:26 +00:00
nikhilmantri0902
113685fbdb refactor(sqlmigration): create the rule_view index via bun builder
Matches the package's index precedent and idx_ naming.
2026-09-10 12:45:05 +05:30
nikhilmantri0902
b9c306dd26 refactor(ruletypes): share list filter validation between params and views
ListRulesParams and RuleViewData now embed one ListFilter with a single
Validate, mirroring dashboards; filter errors surface as rule_list_invalid.
2026-09-10 12:36:24 +05:30
Nikhil Soni
c9ae10b1c0 feat(apiserver): move apiserver to registry and make it configurable (#12493)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Make server port configurable so multiple instances can be started for
agentic development and testing.
- Add `make go-stop` to make it easier to restart server by agents. It
does a graceful stop to allow the Prometheus metrics exporter port to
shutdown otherwise that port remain occupied.
- Add make target for generating the OpenAPI specs.
- Documents the above in `docs/contributing/development.md` under "How
do I run more than one instance?", including `make go-stop` in the basic
backend flow.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 17:12:05 +00:00
nikhilmantri0902
7e71d4507c Merge remote-tracking branch 'origin/feat/common_out_visitors_and_sql_parser' into feat/alerts_listing_page_revamp 2026-09-09 20:10:09 +05:30
nikhilmantri0902
e13cb08097 refactor(filterquery): collapse the builder into the visitor
One Visitor struct now carries sb, fmter and errors like the old
per-feature visitors did; FieldResolver stays the only new concept.
2026-09-09 20:08:33 +05:30
Pandey
84a802edba docs(security): use GitHub private vulnerability reporting (#12820)
#### Description

- Switches the vulnerability reporting channel in `SECURITY.md` to
GitHub's private vulnerability reporting (Security → Report a
vulnerability), which is already enabled on this repo.
- Keeps `security@signoz.io` as a fallback for reporters who can't use
GitHub.
- Adds a short how-to and sets expectations for what happens after a
report (private advisory, coordinated fix, credit + CVE).
- Adds a `CODEOWNERS` entry so `SECURITY.md` is owned by @therealpandey.

#### Additional Information

- Docs / repo config only; no code changes.
2026-09-09 13:52:53 +00:00
Pandey
f78bd492d8 fix(tracefunnel): require view access on trace funnel analytics endpoints (#12817)
#### Description

- The twelve `/api/v1/trace-funnels/analytics/*` route registrations
were missing an authorization wrapper. The six payload routes
(`/analytics/*`) were reachable without authentication; the six
`/{funnel_id}/analytics/*` routes ran without the role check the
middleware applies.
- Wrap all twelve in `am.ViewAccess`, matching the sibling trace-funnel
CRUD routes, so they require an authenticated viewer.

#### Additional Information

- Applies to both community and enterprise editions
(`RegisterTraceFunnelsRoutes` is shared by both servers).
- No change for authenticated viewers; anonymous and role-less callers
now get 401/403.
- Security advisory:
https://github.com/SigNoz/signoz/security/advisories/GHSA-v549-7j2x-qjm5.
2026-09-09 12:39:46 +00:00
nikhilmantri0902
1e7aaa9f14 feat(ruler): add saved views CRUD for the alerts list page
A saved view stores the v3 list params (query, states, sort, order) in a
new rule_view table and replays them; shapes mirror dashboard views.
2026-09-09 15:57:47 +05:30
Gaurav Tewari
99dcd79979 chore(llm-observability): fork shared traces explorer files (#12795)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Copies, unmodified, the traces explorer files that the AI
observability explorer is about to diverge from. The point is the *next*
PR (#12796): with this baseline landed, that diff shows only the real
changes instead of ~1000 lines of brand-new files.
- No behaviour change. Nothing outside the fork imports the new copies
yet — the explorer's views are wired to them in #12796, alongside the
changes that need them.


Sources, all taken from this PR's merge base:

| Copied to `container/LLMObservability/` | Source |
| --- | --- |
| `ToolbarActions/LeftToolbarActions.tsx` |
`container/QueryBuilder/components/ToolbarActions/LeftToolbarActions.tsx`
|
| `ToolbarActions/ToolbarActions.styles.scss` |
`container/QueryBuilder/components/ToolbarActions/ToolbarActions.styles.scss`
|
| `Explorer/aiActions.ts` | `pages/TracesExplorer/aiActions.ts` |
| `Explorer/Controls/*` | `container/TracesExplorer/Controls/*` |
| `Explorer/TraceLoading/*` | `container/TracesExplorer/TraceLoading/*`
|
| `Explorer/TracesTable/*` | `container/TracesExplorer/TracesTable/*` |
| `Explorer/ListView/utils.tsx` |
`container/TracesExplorer/ListView/utils.tsx` |

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-09 09:40:13 +00:00
nikhilmantri0902
09ab9f4081 Merge branch 'feat/common_out_visitors_and_sql_parser' into feat/alerts_listing_page_revamp 2026-09-09 11:41:05 +05:30
nikhilmantri0902
f945b5f513 refactor(dashboard): port the list filter to the shared sqlcompiler
The visitor moves to a key-policy resolver plus an error-code wrap;
emitted SQL is unchanged, pinned by the existing exact-SQL unit suite.
2026-09-09 11:33:35 +05:30
nikhilmantri0902
8b0ab0ff26 refactor(filterquery): add shared list filter SQL compiler
Extracted from the dashboards list visitor: grammar walk, operator
dispatch, value extraction, LIKE builders and the Compiled output type,
behind a per-feature FieldResolver. Scope is list pages over the
relational store; telemetry queries stay on querybuilder. Also rejects
LIKE and ILIKE patterns ending in an unescaped backslash, which never
match on sqlite and abort the query on Postgres.
2026-09-09 11:33:34 +05:30
Nikhil Mantri
f2679a6866 Merge branch 'main' into feat/alerts_listing_page_revamp 2026-09-08 17:47:29 +05:30
nikhilmantri0902
253a117849 refactor: address list API nits
Use fmt.Sprintf over string concatenation in the compiler, rule store
and JSON formatters; build the enum value lists once as package vars;
collapse the repeated integration seed blocks into a seed_alert_rules
fixture and drop name-restating fixture docstrings.
2026-09-08 17:42:46 +05:30
nikhilmantri0902
c9538be38f style: trim list API comments to one line per the repo comment rules 2026-09-08 17:16:18 +05:30
nikhilmantri0902
be51c37317 fix(sqlcompiler): reject LIKE patterns ending in an unescaped backslash
Such a pattern never matches on sqlite and aborts the query on Postgres
when the matcher consumes the dangling escape, turning user input into
a data-dependent 500. Reject it as a 400 on both list endpoints; a
literal trailing backslash stays expressible as an escaped backslash.
Also move the Compiled type into sqlcompiler, leaving each feature only
the error-code wrap.
2026-09-08 17:16:11 +05:30
nikhilmantri0902
66796d1767 refactor: extract shared filter query SQL compiler from dashboards and rules
The dashboards and rules list visitors were near-identical: boolean
composition, operator dispatch, value extraction and the LIKE family
builders. Move that core to pkg/parser/filterquery/sqlcompiler behind a
FieldResolver interface; each feature keeps only its key policy. Scope
is the bun-managed relational store, telemetry stays on querybuilder.
Verified by both features' unchanged exact-SQL unit suites, the
dashboard integration suite and the rules list suite on both providers.
2026-09-08 15:44:38 +05:30
nikhilmantri0902
8e19b17855 test(sqlrulestore): pin deep-nesting compile cases before visitor extraction
Four hand-derived cases covering mixed predicate kinds, parenthesized
OR groups, NOT over a group and three-level nesting with free text and
a timestamp, matching the depth of dashboards' ComplexExamples suite.
2026-09-08 15:06:11 +05:30
nikhilmantri0902
8885497e14 chore: further cleanup 2026-09-08 12:31:33 +05:30
nikhilmantri0902
626e047485 refactor: trim comments to constraint-only per repo comment rules
Rationale and restatements move out of source; dialect reasoning for
LIKE ESCAPE and ILIKE lowering is documented once in the PR body.
2026-09-08 12:13:16 +05:30
nikhilmantri0902
cc3d86c3ec fix(rules): take the read lock in TriggeredAlerts
The lock has been commented out since the method landed, leaving the
rules map read unguarded against concurrent manager writes. Same class
of race this branch fixed in ListRuleStates and GetRule.
2026-09-08 11:54:05 +05:30
nikhilmantri0902
0100083284 fix(sqlstore): drop unusable quote escape from sqlite JSON map paths
sqlite JSON paths have no backslash escapes, so escaping a double quote
in the key produced a path sqlite cannot parse. The character is also
unreachable: the filter grammar's KEY token cannot contain a quote.
Keep the backslash escape, which matches sqlite's raw key comparison.
2026-09-08 11:44:05 +05:30
Nikhil Mantri
648e945471 Merge branch 'main' into feat/alerts_listing_page_revamp 2026-09-08 11:07:26 +05:30
nikhilmantri0902
7323d5ae0b fix(ruletypes): make list sort deterministic on ties
Ties on the primary sort key kept arbitrary DB order, so rows could
shuffle between page requests causing overlaps or misses. Break ties on
name (case-insensitive) then id, always ascending, with the requested
order applied to the primary key only. Pin the tiebreak in unit tests
and paginate over state ties in the integration suite.
2026-09-08 01:16:39 +05:30
nikhilmantri0902
578b9172ba test(integration): cover the rules list v3 API on both sqlstore providers
Seeds five rules spanning every filter axis and pins the envelope, slim rows,
DSL filters (incl. dotted label keys and missing-label-as-empty semantics),
states param, sort ranks, pagination totals and the error contract; adds
delete_all_rules and an idempotent ensure_notification_channel fixture fn.
Verified against both sqlite and postgres providers.
2026-09-07 20:20:54 +05:30
nikhilmantri0902
2110006d17 feat(sqlrulestore): treat a missing label as empty string for all label value operators
COALESCE applies uniformly instead of only on negations, so labels.key = ''
also matches label-less rules; EXISTS/NOT EXISTS remain the presence checks.
2026-09-07 17:39:41 +05:30
nikhilmantri0902
01dda19d17 feat(sqlrulestore): align label filter semantics with the querier
severity takes the labels operator set (it is an alias for labels.severity,
so EXISTS/NOT EXISTS now work on it), and negative label operators evaluate a
missing label as the empty string instead of always matching, mirroring the
querier's AddDefaultExistsFilter map-attribute semantics.
2026-09-07 17:27:07 +05:30
nikhilmantri0902
b519574b89 feat(ruler): add GET /api/v3/rules route and regenerate API clients
Registers ListRulesV3 with the list params and envelope, marks the v2
ListRules operation deprecated, and regenerates the OpenAPI spec and the
frontend client.
2026-09-07 14:54:46 +05:30
nikhilmantri0902
629ecabce7 feat(ruler): list rules with SQL filter pushdown, state overlay and in-memory pagination
DSL query compiles into the store's WHERE; state is overlaid from a snapshot
of the rule manager map taken under RLock, then the states filter, total,
sort (state/severity rank comparators) and offset/limit run in code so total
always matches what is pageable. StorableRule gains alias:rule to match the
compiler's column references. Also guards the previously unlocked m.rules
reads in ListRuleStates and GetRule.
2026-09-07 14:43:40 +05:30
nikhilmantri0902
131e302c55 feat(sqlrulestore): compile rule list filter DSL to SQL
Adds JSONExtractMapValue to SQLFormatter so a labels map key is one path
segment (dotted label keys work on both dialects), and a visitor over the
shared filterquery grammar mapping rule list DSL keys to SQL.
2026-09-07 13:58:30 +05:30
nikhilmantri0902
239c92ba67 feat(ruletypes): add list params, filter allow-lists and listable rule types for rules list API 2026-09-07 12:48:49 +05:30
132 changed files with 7149 additions and 3293 deletions

7
.claude/opencode.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": true,
"experimental": {
"disable_paste_summary": true
}
}

View File

@@ -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).

View 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
View 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.

View File

@@ -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
View File

@@ -15,6 +15,10 @@
.github @therealpandey
go.mod @therealpandey
# Security
/SECURITY.md @therealpandey
# Scaffold Owners
/pkg/config/ @therealpandey

1
.gitignore vendored
View File

@@ -232,3 +232,4 @@ pyrightconfig.json
# dev
.dev/
.claude/worktrees/
.claude/settings.local.json

View File

@@ -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 -

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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{}),
}

View File

@@ -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

View File

@@ -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("...)

View File

@@ -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

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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,

View File

@@ -333,7 +333,6 @@ describe('AttributeMappingsTab (integration)', () => {
context: FieldContext.attribute,
operation: MapperOperation.copy,
priority,
enabled: true,
})),
},
}),

View File

@@ -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>

View File

@@ -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({

View File

@@ -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}`}

View File

@@ -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,
)
);
}

View File

@@ -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[];
}

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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[];
};

View File

@@ -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;
}
}
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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']);

View File

@@ -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} />,
};
}

View File

@@ -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);
}

View File

@@ -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];

View 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.` };
},
};
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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', () => {

View File

@@ -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,
};
});
}

View File

@@ -173,7 +173,7 @@ function DrilldownAggregateMenu({
void logEvent(DashboardDetailEvents.DrilldownAction, {
action: 'contextLink',
});
openInNewTab(link.url);
openInNewTab(link.url, !!link.targetBlank);
onClose();
}}
>

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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"},

View File

@@ -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) {

View File

@@ -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))
}

View 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)
}

View 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)
}

View 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)
}

View File

@@ -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
}

View File

@@ -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
}

View 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
}

View File

@@ -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
}

View 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),
)
}

View File

@@ -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)
}
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}
]
}
}
]
}
}

View File

@@ -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
}
]
}
}
]
}
}

View File

@@ -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
}
]
}
}
]
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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).

View File

@@ -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.

View 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
}

View 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))
})
}
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View 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
}

View 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
}()

View 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
}

View File

@@ -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.

View 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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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++ {

View File

@@ -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