Compare commits

...

40 Commits

Author SHA1 Message Date
nityanandagohain
9098e683e0 fix: disable cache for ai query 2026-08-18 15:17:32 +05:30
nityanandagohain
a53b937854 Merge remote-tracking branch 'origin/main' into issue_5602 2026-08-18 14:19:32 +05:30
Nityananda Gohain
b86e536432 fix: support for query type in cache fingerprint (#12598)
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
We have added a new query type "builder_ai_query" , now previously the
cache fingerprint didn't consider type because of which ai query cache
and builder query cache might result in same fingerprint leading to
cache issue.

#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/5602 and
https://github.com/SigNoz/engineering-pod/issues/5603
2026-08-18 08:38:03 +00:00
Tushar Vats
a76a7ede70 fix(logs pipelines): default parse_from to body.message under JSON bodies (#12556)
#### Description

- The processor form pre-fills `parse_from` with `body` for the grok,
regex and json parsers (`initialValue: 'body'` in
`AddNewProcessor/config.ts`).
- With `use_json_body` the collector prepends a `normalize` pipeline, so
by the time user operators run the body is a map. A parser pointed at
`body` gets a map it cannot parse and silently extracts nothing — the
pipeline is broken by default, without the user ever touching the field.
- Resolve the default to `body.message` when the flag is on. Keying off
`initialValue === 'body'` rather than a hardcoded processor list keeps
`time_parser` (`attributes.timestamp`) and `severity_parser`
(`attributes.logLevel`) untouched, and covers any future processor that
defaults to the body.

#### Additional Information

- Saved processors are unaffected — edit mode calls
`form.setFieldsValue(savedData)`, which overrides `initialValue`. This
only changes what a newly added processor starts with, and only while
the flag is on.
- The helper returns new objects rather than mutating the shared config;
there is a test asserting `processorFields.grok_parser` still reads
`body`.
- This does not help pipelines already saved with a bare `body`. Preview
shows them making no change at all, with nothing explaining why —
surfacing that is a follow-up.
- Filters have the same problem and are not addressed here: `body
contains "x"` cannot match a map, and unlike a failing operator a
skipped filter produces no collector log. `queryBuilderToExpr` already
special-cases `body.<key>` for EXISTS; extending that to value
comparisons is the separate fix.
2026-08-18 08:25:56 +00:00
nityanandagohain
606511df34 Merge remote-tracking branch 'origin/main' into issue_5602 2026-08-18 12:12:10 +05:30
Srikanth Chekuri
cfc7a04bc8 feat: resolve semantic convention names in trace queries (#12442)
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

- A referenced name in a trace query now resolves to a `LogicalField`
(#12499): one field, addressed by the requested spelling, backed by its
physical member keys. A semantic-convention family
(`deployment.environment.name` / `deployment.environment`) merges into
one expression with current-wins precedence; the response keeps the
requested spelling.
- `FieldMapper` gets one new method, `ExistsFor` (the per-key presence
primitive). `LogicalValueExpr` and `LogicalExistsExpr` build all family
SQL in one place from `FieldFor` and `ExistsFor`; no signal implements
family logic.
- Statement builders prefetch sibling spellings; the metadata store
stays family-blind and autocomplete stays literal. Traces and the
resource filter compile per logical field; logs, metrics, and the other
signals keep their SQL unchanged.
- The `resolve_semconv_families` feature flag (default: disabled) gates
all family behavior. With the flag off, the generated SQL is the same as
main; tests pin this. Part of #6143.

#### Additional Information

- Stack: #12441 (merged) → **#12442** → #12443#12444#12445#12446#12447. This layer bases on main.
- Rollback: turn the flag off; stored telemetry is untouched.
2026-08-17 21:59:26 +00:00
Vikrant Gupta
014cab564e feat(serviceaccount): assign roles through the service_account_roles API (#12589)
#### Description

- Moves the service account role drawer off the deprecated nested
`/api/v1/service_accounts/{id}/roles` endpoints onto
`/api/v1/service_account_roles`, mirroring the earlier member →
`user_roles` migration.
- `useServiceAccountRoleManager` now reads role assignments from the
service account detail (`serviceAccountRoles` join rows), creates with
`{serviceAccountId, roleId}`, and deletes by the join-row id; the manual
query invalidation is dropped since the drawer already refetches the
same query.

#### Screenshots / Screen Recordings



https://github.com/user-attachments/assets/dd60e1d1-5d78-4f17-80f7-bb1a53730736
2026-08-17 20:51:49 +00:00
Vikrant Gupta
9cf2ffe000 test(serviceaccount): move role provisioning to the service_account_roles API (#12590)
#### Description

- Moves the `serviceaccount` integration fixtures and suites off the
deprecated nested `/api/v1/service_accounts/{id}/roles` endpoints onto
`/api/v1/service_account_roles`.
- Roles are assigned via `POST /api/v1/service_account_roles` (201) and
revoked via `DELETE /api/v1/service_account_roles/{id}` (204), reading
join-row ids from the service account detail.

#### Additional Information

- Part of SigNoz/platform-pod#2919 — the integration-test half of the
consumer migration. The frontend migration and the deprecated-endpoint
removal are separate PRs.
2026-08-17 20:42:25 +00:00
Vikrant Gupta
81d1716c7e fix(user): clear role assignments when a user is deleted (#12554)
#### Description

- Soft-deleting a user revoked the FGA grant but left the `user_role`
rows behind. The role-delete guard (`OnBeforeRoleDelete` →
`GetUsersByOrgIDAndRoleID`) still counted the deleted user, so the role
could never be deleted — and detaching the assignment was also blocked
because the user is deleted. That left the role permanently undeletable.
- `SoftDeleteUser` now deletes the user's `user_role` rows in the same
transaction that already clears its password, tokens, and preferences,
so the SQL side matches the FGA revoke.
- Migration `delete_orphan_user_roles` clears the orphan `user_role`
rows left by users deleted before this change.

#### Additional Information

- Regression test in `role/02_crud.py`: assign a custom role to a user,
delete the user, then delete the role → now `204` (was the deadlock).
2026-08-17 19:11:43 +00:00
Gaurav Tewari
a7935ca668 test(qb): de-flake the recent searches dropdown (#12586)
#### Description

`RecentSearches.test.tsx` was failing intermittently on CI. Two separate
timing races, both in the test itself:

- Clicking a recent used `userEvent.click`, whose `pointerdown` blurs
the editor and closes the dropdown ~10ms later — before CodeMirror
applies the completion on `mousedown`. On a slow runner the dropdown was
already gone. Now uses `fireEvent.mouseDown`, which is what a browser
actually does here.
- `filters recents by substring as the user types` waited on a dropdown
that typing can close, with nothing to reopen it. All waits now
re-request completions if it closed.

No production code changed.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information

Ran the file 5x, the whole `QueryBuilderV2` directory 3x (211 tests),
and 3x under CPU load to mimic a slow runner — all green.

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-17 19:09:23 +00:00
Vinicius Lourenço
30823c520d refactor(infrastructure-monitoring): always represent IO as binBps instead of bytes for hosts (#12428)
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

The other charts related to network uses binBps instead of `bytes`, and
similar to System Disk IO, we can use binBps since we are representing
throghutput

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

> The changes are on `Network Usage` and `System Disk Chart`, pay
attention to the Y unit.

Before:

<img width="1726" height="1090" alt="image"
src="https://github.com/user-attachments/assets/6a71f9fd-ae19-4ed8-bba1-3851ecf815f4"
/>

After:

<img width="1726" height="1091" alt="image"
src="https://github.com/user-attachments/assets/b3fc41dc-af20-4784-afe2-c3c1936ceba8"
/>

#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

Closes https://github.com/SigNoz/pulse-pod/issues/211

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [ ] 🐛 Bug fix
- [x] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated: No
- Manual verification: Yes
- Edge cases covered: -

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Infrastructure Monitoring - Hosts
- Potential regressions: None
- Rollback plan: Revert this commit

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Feature |
| Description | We updated the charts of Network IO and System disk IO
to use unit of bytes per second instead of bytes. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-17 18:42:58 +00:00
Gaurav Tewari
8e4aedcecd chore: dependencies update resolutions (#12583)
<!--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.-->
#### Description

- Clears all 19 high-severity advisories reported by `pnpm audit` (24
findings → 4, none high).
- Most of this is security floors in `pnpm-workspace.yaml`, following
the file's existing capped-override convention — each entry records the
vulnerable path and what would let us drop it again.
- `brace-expansion` needs three separate entries because three majors
coexist in the tree: minimatch@3 (via `test-exclude`), minimatch@9 (via
jest's `glob@10`), and minimatch@10 (via `eslint-plugin-sonarjs`).
- `image-size` has no patched release at all, so the only fix is
dropping the dependency — `less@4.5.0` removed it, and that lands inside
`typescript-plugin-css-modules`' `^4.2.0` range.
- `postcss` 8.5.14 → 8.5.26 is the one direct bump; it's a direct
devDep, so a floor override would only hide a stale version in
`package.json`.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=230120135&issue=SigNoz%7Cengineering-pod%7C5925

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Deliberately scoped to highs. The 4 remaining moderates (`dompurify`,
`@remix-run/router`, and two `react-router` advisories) are left for a
follow-up.
<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-08-17 18:13:06 +00:00
Vinicius Lourenço
1c7fd32f91 fix(infrastructure-monitoring): do not overflow outside table for large error message (#12450)
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

Prevent the error message to overflow outside the table.

#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the
behavior before the change and the result after the change. This helps
reviewers quickly understand the impact and verify the update.

Before:


https://github.com/user-attachments/assets/d53845ed-7db9-4451-a2ca-31bc6127ec31

After:


https://github.com/user-attachments/assets/78e1e2d4-a5e1-46d7-9f66-117d1868e9f5

#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.

Closes https://github.com/SigNoz/pulse-pod/issues/222

---

###  Change Type
_Select all that apply_

- [ ]  Feature
- [x] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---

### 🧪 Testing Strategy
> How was this change validated?

- Tests added/updated: No
- Manual verification: Yes
- Edge cases covered: -

---

### ⚠️ Risk & Impact Assessment
> What could break? How do we recover?

- Blast radius: Infrastructure Monitoring
- Potential regressions: -
- Rollback plan: Revert this commit

---

### 📝 Changelog
> Fill only if this affects users, APIs, UI, or documented behavior  
> Use **N/A** for internal or non-user-facing changes

| Field | Value |
|------|-------|
| Deployment Type | Cloud / OSS / Enterprise |
| Change Type | Bug Fix |
| Description | We updated the error layout to ensure it won't overflow
the table in case the APIs fail with a large message. |

---

### 📋 Checklist
- [x] Tests added or explicitly not required
- [x] Manually tested
- [ ] Breaking changes documented
- [ ] Backward compatibility considered
2026-08-17 17:49:51 +00:00
nityanandagohain
640072a570 fix: more cleanup 2026-08-17 18:28:37 +05:30
nityanandagohain
6c7c4a333c fix: more fixes 2026-08-17 18:14:35 +05:30
Aditya Singh
fb106b3253 feat(logs): render DataViewer with filter/group-by actions in log details overview (#12426)
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
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

Renders the new `DataViewer` (Pretty tree + JSON) inside the V2
log-details Overview tab
Wires up filter / group-by on each attribute similar to pretty view in
trace details

**Change points**

**Rendering the View**
- Data Viewer renders using `aggregateAttributesResourcesToObject` which
is not written fresh, just extracted from an existing logic
`aggregateAttributesResourcesToString`.
- jsonData is separately sent to the DataViewer as rendering logic for
pretty and json view is different in this case. unlike trace details
where both views had same source data.

**Group by / FIlter and other logic**

- All the related logic resides in 2 major files:
`useLogAttributeActions.tsx` and `logAttributeActions.utils.ts`.
- we build the fieldKey ourselves (`buildLogFilterTarget`) as this is
now different from old representation.
- filter / group by / replace build the query locally now...we do not
make the `getAggregateKeys` call at all. we fabricate the telemetry
field key ourselves with just the name and dataType filled and rest kept
empty. so no prefetch, no resolver, no loader.
- the query building is extracted into 3 utils: `getFilterQueryData`,
`getGroupByQueryData`, `getReplaceFilterQueryData`. the hook just calls
these over `updateQueriesData`.
- restricted fields apply for body as well: `timestamp` / `id` / `date`
inside body no longer show filter / group by. reuses
`RESTRICTED_SELECTED_FIELDS`.

**Pretty View**
- renderLeafValue: introduce to render custom leaf value. we are using
this here for body. This is extensible for other usecases as well...like
showing md format leaf for LLMs in the future.
- fixed leaf vs nested row indentation so keys line up at every depth.

**Other changes**
- Filter value keeps its data type: dataType is threaded through so a
numeric/bool filter value stays unquoted.
- Removed the redundant outer JSON tab in V2.


#### Screenshots / Screen Recordings (if applicable)


https://github.com/user-attachments/assets/ec1f0842-0a3b-407b-807c-0c3b0f9ed86a



#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.
Closes: https://github.com/SigNoz/engineering-pod/issues/4618
Closes: https://github.com/SigNoz/engineering-pod/issues/4630
Closes: https://github.com/SigNoz/engineering-pod/issues/5781
---

###  Change Type
_Select all that apply_

- [x]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---
2026-08-17 11:11:26 +00:00
nityanandagohain
15fb851ce0 Merge remote-tracking branch 'origin/main' into issue_5602 2026-08-17 15:45:21 +05:30
nityanandagohain
3e73614f34 fix: respect claude rules 2026-08-12 01:05:54 +05:30
nityanandagohain
07979db754 Merge remote-tracking branch 'origin/main' into issue_5602 2026-08-12 00:50:24 +05:30
nityanandagohain
6e8659006c fix: fix aliasing issue 2026-08-11 14:56:09 +05:30
nityanandagohain
c138aa7da6 Merge remote-tracking branch 'origin/main' into issue_5602 2026-08-10 18:56:42 +05:30
Nityananda Gohain
0d3f7ed51d Merge branch 'main' into issue_5602 2026-08-06 18:05:33 +05:30
nityanandagohain
28f0e06c55 fix: lint issue 2026-08-06 17:58:45 +05:30
nityanandagohain
19c5cb9984 fix: more cleanup 2026-08-06 17:53:18 +05:30
nityanandagohain
f6a823c000 fix: remove unnecessary function 2026-08-06 15:46:50 +05:30
nityanandagohain
23f03973c5 fix: more cleanup 2026-08-06 12:23:06 +05:30
nityanandagohain
0c0e969cfc fix: more cleanup 2026-08-06 00:06:41 +05:30
nityanandagohain
c870efa12d Merge remote-tracking branch 'origin/main' into issue_5602 2026-08-05 11:30:36 +05:30
nityanandagohain
d250f190a7 Squashed commit of the following:
commit 6d0b607f41b80583b2f4dc015c728d57fd0a3f6a
Author: nityanandagohain <nityanandagohain@gmail.com>
Date:   Wed Jul 15 10:30:43 2026 +0530

    fix: update tests

commit 20f42474b0901e49cc745155a2c70bb829988a94
Merge: 36dca5dab0 31efe177a4
Author: nityanandagohain <nityanandagohain@gmail.com>
Date:   Tue Jul 14 23:09:00 2026 +0530

    Merge branch 'issue_5601': scoped-trace builder package + port aggregations

    Merges the telemetryscopedtraces refactor (generic scoped-trace topology moved
    out of telemetryai, feature-flagged gen_ai key enrichment, tracefield. context
    classification) and ports the scalar/time-series work onto it:

    - trace_aggregation.go / trace_having.go now live in telemetryscopedtraces
    - trace-level HAVING resolution composes the canonical pkg/variables
      replacement (dynamic __all__ drops a condition for any operator) with the
      standard filter pipeline, so values still bind as args
    - buildPerTraceScan adopts the merged embedExpr placeholder/arg checking
    - ColumnProvider gains ActivityGateAlias; gen_ai provider gates trace-level
      aggregations on llm_call_count

commit 36dca5dab0c0abb08aeb2645fee07f28cc764eb8
Author: nityanandagohain <nityanandagohain@gmail.com>
Date:   Tue Jul 14 23:09:00 2026 +0530

    feat: trace-level aggregations for scalar/time-series and trace-scoped span list

    - trace.-prefixed aggregations (avg(trace.output_tokens), count(trace.trace_id),
      arithmetic) run over window-clipped per-trace values via a native CTE pipeline
    - a trace-level filter condition qualifies traces in scalar/time-series/raw via
      __qualified / __trace_scope, resource-fingerprint pruned like the trace list
    - trace-level filter conditions resolve through the standard filter pipeline
      (bound args, operators, query variables) against the per-trace column aliases
    - per-trace rows with no LLM span in the window are dropped from trace-level
      aggregations (LLM-activity gate)
    - targeted rejections for group-by/order-by on trace-level columns
2026-07-15 12:38:57 +05:30
nityanandagohain
97c49c870b feat: support ai trace aggregate filtering in ai span list 2026-07-15 12:27:55 +05:30
nityanandagohain
31efe177a4 fix: address comments 2026-07-14 18:53:30 +05:30
nityanandagohain
d502d12ac3 fix: update openapi 2026-07-10 14:27:07 +05:30
nityanandagohain
bd9f15a716 fix: update integration test 2026-07-10 14:21:16 +05:30
nityanandagohain
813ef988c9 fix: edge cases and correct cost key 2026-07-10 12:06:34 +05:30
nityanandagohain
40e6799285 fix: add resource fingerprint cte 2026-07-10 00:36:25 +05:30
nityanandagohain
1caa60a3cd fix: cleanup and more tests 2026-07-09 23:54:05 +05:30
nityanandagohain
3f781f0083 fix: more cleanup 2026-07-09 12:39:01 +05:30
nityanandagohain
6aec05cf7a fix: more tests 2026-07-09 08:45:22 +05:30
nityanandagohain
683a52f35a fix: take perf into consideration 2026-07-09 08:45:22 +05:30
nityanandagohain
e924fa1e62 feat: support llm trace list and span list 2026-07-09 08:45:20 +05:30
90 changed files with 5891 additions and 751 deletions

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -197,7 +197,7 @@
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"oxlint-tsgolint": "0.23.0",
"postcss": "8.5.14",
"postcss": "8.5.26",
"postcss-scss": "4.0.9",
"react-resizable": "3.0.4",
"redux-mock-store": "1.5.4",

316
frontend/pnpm-lock.yaml generated
View File

@@ -6,12 +6,19 @@ settings:
overrides:
'@babel/core@<=7.29.0': '>=7.29.6 <8'
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.1 <5'
brace-expansion@<1.1.18: '>=1.1.18 <2'
brace-expansion@>=2.0.0 <2.1.4: '>=2.1.4 <3'
brace-expansion@>=5.0.0 <5.0.9: '>=5.0.9 <6'
cookie@<0.7.0: '>=0.7.1 <1'
dompurify@<=3.4.10: '>=3.4.11 <4'
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
fast-uri@<3.1.5: '>=3.1.5 <4'
immutable@<5.1.8: '>=5.1.8 <6'
js-cookie@<=3.0.5: '>=3.0.7 <4'
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
less@<4.5.0: '>=4.5.0 <5'
nanoid@<3.3.18: '>=3.3.18 <4'
prismjs@<1.30.0: '>=1.30.0 <2'
react-router@>=6.7.0 <6.30.4: '>=6.30.4 <7'
tmp@<0.2.6: '>=0.2.6 <0.3.0'
@@ -98,7 +105,7 @@ importers:
version: 3.3.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@vitejs/plugin-react':
specifier: 5.1.4
version: 5.1.4(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))
version: 5.1.4(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))
ansi-to-html:
specifier: 0.7.2
version: 0.7.2
@@ -311,10 +318,10 @@ importers:
version: 14.0.1
vite:
specifier: npm:rolldown-vite@7.3.1
version: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
version: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite-plugin-html:
specifier: 3.2.2
version: 3.2.2(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))
version: 3.2.2(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))
zod:
specifier: 4.3.6
version: 4.3.6
@@ -467,11 +474,11 @@ importers:
specifier: 0.23.0
version: 0.23.0
postcss:
specifier: 8.5.14
version: 8.5.14
specifier: 8.5.26
version: 8.5.26
postcss-scss:
specifier: 4.0.9
version: 4.0.9(postcss@8.5.14)
version: 4.0.9(postcss@8.5.26)
react-resizable:
specifier: 3.0.4
version: 3.0.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -501,16 +508,16 @@ importers:
version: 1.6.0(react@18.2.0)
vite-plugin-checker:
specifier: 0.12.0
version: 0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3)
version: 0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3)
vite-plugin-compression:
specifier: 0.5.1
version: 0.5.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))
version: 0.5.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))
vite-plugin-image-optimizer:
specifier: 2.0.3
version: 2.0.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(sharp@0.35.0)(svgo@4.0.2)
version: 2.0.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(sharp@0.35.0)(svgo@4.0.2)
vite-tsconfig-paths:
specifier: 6.1.1
version: 6.1.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(typescript@5.9.3)
version: 6.1.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(typescript@5.9.3)
packages:
@@ -4159,15 +4166,15 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
brace-expansion@1.1.18:
resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -4459,8 +4466,9 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
copy-anything@2.0.6:
resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
copy-text-to-clipboard@3.2.2:
resolution: {integrity: sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==}
@@ -4707,6 +4715,14 @@ packages:
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -5100,8 +5116,8 @@ packages:
fast-shallow-equal@1.0.0:
resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==}
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fast_array_intersect@1.1.0:
resolution: {integrity: sha512-/DCilZlUdz2XyNDF+ASs0PwY+RKG9Y4Silp/gbS72Cvbg4oibc778xcecg+pnNyiNHYgh/TApsiDTjpdniyShw==}
@@ -5556,19 +5572,14 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
image-size@0.5.5:
resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==}
engines: {node: '>=0.10.0'}
hasBin: true
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@11.1.3:
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
immutable@5.1.5:
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
immutable@5.1.9:
resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
@@ -5811,8 +5822,9 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-what@3.14.1:
resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
@@ -6089,8 +6101,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsdom@20.0.3:
@@ -6173,9 +6185,9 @@ packages:
lerc@3.0.0:
resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==}
less@4.4.0:
resolution: {integrity: sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==}
engines: {node: '>=14'}
less@4.9.0:
resolution: {integrity: sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==}
engines: {node: '>=18'}
hasBin: true
leven@3.1.0:
@@ -6364,14 +6376,14 @@ packages:
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
engines: {node: '>=12'}
make-dir@2.1.0:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'}
make-dir@3.1.0:
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
engines: {node: '>=8'}
make-dir@5.1.0:
resolution: {integrity: sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==}
engines: {node: '>=18'}
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
@@ -6691,6 +6703,9 @@ packages:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
@@ -6719,8 +6734,8 @@ packages:
nano-time@1.0.0:
resolution: {integrity: sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -6732,6 +6747,11 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
needle@2.9.1:
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
engines: {node: '>= 4.4.x'}
hasBin: true
needle@3.2.0:
resolution: {integrity: sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==}
engines: {node: '>= 4.4.x'}
@@ -7066,10 +7086,6 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
@@ -7131,8 +7147,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.14:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.298.0:
@@ -7178,6 +7194,9 @@ packages:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
probe-image-size@7.4.0:
resolution: {integrity: sha512-cdEprVtZxV+awMde9X+4jILBFYh4CARxVrQaMl4wY4YcPWbul9jntXrIW95NInBDyJwcVUP3U0T6yukN8rMBaQ==}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -7924,7 +7943,7 @@ packages:
'@types/node': ^20.19.0 || >=22.12.0
esbuild: '>=0.28.1 <0.29.0'
jiti: '>=1.21.0'
less: ^4.0.0
less: '>=4.5.0 <5'
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
@@ -8044,10 +8063,6 @@ packages:
resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==}
engines: {node: ^14.0.0 || >=16.0.0}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -8205,6 +8220,9 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-parser@0.3.1:
resolution: {integrity: sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==}
strict-event-emitter@0.2.8:
resolution: {integrity: sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A==}
@@ -9069,7 +9087,7 @@ snapshots:
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.3.0
js-yaml: 4.3.1
'@babel/code-frame@7.29.0':
dependencies:
@@ -10569,7 +10587,7 @@ snapshots:
camelcase: 5.3.1
find-up: 4.1.0
get-package-type: 0.1.0
js-yaml: 4.3.0
js-yaml: 4.3.1
resolve-from: 5.0.0
'@istanbuljs/schema@0.1.3': {}
@@ -12428,11 +12446,11 @@ snapshots:
'@types/postcss-modules-local-by-default@4.0.2':
dependencies:
postcss: 8.5.14
postcss: 8.5.26
'@types/postcss-modules-scope@3.0.4':
dependencies:
postcss: 8.5.14
postcss: 8.5.26
'@types/prop-types@15.7.5': {}
@@ -12761,7 +12779,7 @@ snapshots:
d3-time-format: 4.1.0
internmap: 2.0.3
'@vitejs/plugin-react@5.1.4(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))':
'@vitejs/plugin-react@5.1.4(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7)
@@ -12769,7 +12787,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
@@ -12823,7 +12841,7 @@ snapshots:
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.2
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
@@ -13134,16 +13152,16 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@1.1.15:
brace-expansion@1.1.18:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.1.1:
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.7:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -13432,9 +13450,9 @@ snapshots:
cookie@0.7.2: {}
copy-anything@2.0.6:
copy-anything@3.0.5:
dependencies:
is-what: 3.14.1
is-what: 4.1.16
copy-text-to-clipboard@3.2.2: {}
@@ -13472,7 +13490,7 @@ snapshots:
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.3.0
js-yaml: 4.3.1
parse-json: 5.2.0
optionalDependencies:
typescript: 5.9.3
@@ -13682,6 +13700,11 @@ snapshots:
debounce@1.2.1: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
optional: true
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -14141,7 +14164,7 @@ snapshots:
fast-shallow-equal@1.0.0: {}
fast-uri@3.1.2: {}
fast-uri@3.1.5: {}
fast_array_intersect@1.1.0: {}
@@ -14556,7 +14579,7 @@ snapshots:
history@5.3.0:
dependencies:
'@babel/runtime': 7.28.2
'@babel/runtime': 7.29.2
hoist-non-react-statics@3.3.2:
dependencies:
@@ -14635,9 +14658,9 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
icss-utils@5.1.0(postcss@8.5.14):
icss-utils@5.1.0(postcss@8.5.26):
dependencies:
postcss: 8.5.14
postcss: 8.5.26
ieee754@1.2.1: {}
@@ -14645,14 +14668,11 @@ snapshots:
ignore@7.0.5: {}
image-size@0.5.5:
optional: true
immediate@3.0.6: {}
immer@11.1.3: {}
immutable@5.1.5: {}
immutable@5.1.9: {}
import-fresh@3.3.1:
dependencies:
@@ -14871,7 +14891,7 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-what@3.14.1: {}
is-what@4.1.16: {}
is-wsl@3.1.1:
dependencies:
@@ -15429,7 +15449,7 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -15478,7 +15498,7 @@ snapshots:
'@types/json-schema': 7.0.15
'@types/lodash': 4.17.24
is-glob: 4.0.3
js-yaml: 4.3.0
js-yaml: 4.3.1
lodash: 4.18.1
minimist: 1.2.8
prettier: 3.8.3
@@ -15533,18 +15553,17 @@ snapshots:
lerc@3.0.0: {}
less@4.4.0:
less@4.9.0:
dependencies:
copy-anything: 2.0.6
copy-anything: 3.0.5
parse-node-version: 1.0.1
tslib: 2.8.1
optionalDependencies:
errno: 0.1.8
graceful-fs: 4.2.11
image-size: 0.5.5
make-dir: 2.1.0
make-dir: 5.1.0
mime: 1.6.0
needle: 3.2.0
probe-image-size: 7.4.0
source-map: 0.6.1
transitivePeerDependencies:
- supports-color
@@ -15710,16 +15729,13 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-dir@2.1.0:
dependencies:
pify: 4.0.1
semver: 5.7.2
optional: true
make-dir@3.1.0:
dependencies:
semver: 6.3.1
make-dir@5.1.0:
optional: true
make-error@1.3.6: {}
makeerror@1.0.12:
@@ -16123,19 +16139,19 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.7
brace-expansion: 5.0.9
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.15
brace-expansion: 1.1.18
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.1
brace-expansion: 2.1.4
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.1
brace-expansion: 2.1.4
minimist@1.2.8: {}
@@ -16184,6 +16200,9 @@ snapshots:
mri@1.2.0: {}
ms@2.0.0:
optional: true
ms@2.1.2: {}
ms@2.1.3: {}
@@ -16234,12 +16253,21 @@ snapshots:
dependencies:
big-integer: 1.6.51
nanoid@3.3.11: {}
nanoid@3.3.18: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
needle@2.9.1:
dependencies:
debug: 3.2.7
iconv-lite: 0.4.24
sax: 1.6.0
transitivePeerDependencies:
- supports-color
optional: true
needle@3.2.0:
dependencies:
debug: 3.2.7
@@ -16412,7 +16440,7 @@ snapshots:
find-up: 8.0.0
fs-extra: 11.3.3
jiti: 2.6.1
js-yaml: 4.3.0
js-yaml: 4.3.1
remeda: 2.34.0
string-argv: 0.3.2
tsconfck: 3.1.6(typescript@5.9.3)
@@ -16611,9 +16639,6 @@ snapshots:
picomatch@4.0.4: {}
pify@4.0.1:
optional: true
pirates@4.0.7: {}
pkg-dir@4.2.0:
@@ -16622,37 +16647,37 @@ snapshots:
possible-typed-array-names@1.1.0: {}
postcss-load-config@3.1.4(postcss@8.5.14)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3)):
postcss-load-config@3.1.4(postcss@8.5.26)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3)):
dependencies:
lilconfig: 2.1.0
yaml: 1.10.3
optionalDependencies:
postcss: 8.5.14
postcss: 8.5.26
ts-node: 10.9.1(@types/node@16.18.25)(typescript@5.9.3)
postcss-modules-extract-imports@3.0.0(postcss@8.5.14):
postcss-modules-extract-imports@3.0.0(postcss@8.5.26):
dependencies:
postcss: 8.5.14
postcss: 8.5.26
postcss-modules-local-by-default@4.2.0(postcss@8.5.14):
postcss-modules-local-by-default@4.2.0(postcss@8.5.26):
dependencies:
icss-utils: 5.1.0(postcss@8.5.14)
postcss: 8.5.14
icss-utils: 5.1.0(postcss@8.5.26)
postcss: 8.5.26
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
postcss-modules-scope@3.2.1(postcss@8.5.14):
postcss-modules-scope@3.2.1(postcss@8.5.26):
dependencies:
postcss: 8.5.14
postcss: 8.5.26
postcss-selector-parser: 7.1.1
postcss-safe-parser@7.0.1(postcss@8.5.14):
postcss-safe-parser@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.14
postcss: 8.5.26
postcss-scss@4.0.9(postcss@8.5.14):
postcss-scss@4.0.9(postcss@8.5.26):
dependencies:
postcss: 8.5.14
postcss: 8.5.26
postcss-selector-parser@7.1.1:
dependencies:
@@ -16661,9 +16686,9 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.14:
postcss@8.5.26:
dependencies:
nanoid: 3.3.11
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -16714,6 +16739,15 @@ snapshots:
prismjs@1.30.0: {}
probe-image-size@7.4.0:
dependencies:
lodash.merge: 4.6.2
needle: 2.9.1
stream-parser: 0.3.1
transitivePeerDependencies:
- supports-color
optional: true
process-nextick-args@2.0.1: {}
progress@2.0.3: {}
@@ -17616,13 +17650,13 @@ snapshots:
robust-predicates@3.0.2: {}
rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4):
rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4):
dependencies:
'@oxc-project/runtime': 0.101.0
fdir: 6.5.0(picomatch@4.0.4)
lightningcss: 1.31.1
picomatch: 4.0.4
postcss: 8.5.14
postcss: 8.5.26
rolldown: 1.0.0-beta.53
tinyglobby: 0.2.15
optionalDependencies:
@@ -17630,7 +17664,7 @@ snapshots:
esbuild: 0.28.1
fsevents: 2.3.3
jiti: 2.6.1
less: 4.4.0
less: 4.9.0
sass: 1.97.3
stylus: 0.62.0
terser: 5.46.2
@@ -17705,7 +17739,7 @@ snapshots:
sass@1.97.3:
dependencies:
chokidar: 4.0.3
immutable: 5.1.5
immutable: 5.1.9
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.1
@@ -17735,9 +17769,6 @@ snapshots:
refa: 0.12.1
regexp-ast-analysis: 0.7.1
semver@5.7.2:
optional: true
semver@6.3.1: {}
semver@7.8.5: {}
@@ -17924,6 +17955,13 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-parser@0.3.1:
dependencies:
debug: 2.6.9
transitivePeerDependencies:
- supports-color
optional: true
strict-event-emitter@0.2.8:
dependencies:
events: 3.3.0
@@ -18045,8 +18083,8 @@ snapshots:
micromatch: 4.0.8
normalize-path: 3.0.0
picocolors: 1.1.1
postcss: 8.5.14
postcss-safe-parser: 7.0.1(postcss@8.5.14)
postcss: 8.5.26
postcss-safe-parser: 7.0.1(postcss@8.5.26)
postcss-selector-parser: 7.1.1
postcss-value-parser: 4.2.0
string-width: 8.2.0
@@ -18287,14 +18325,14 @@ snapshots:
'@types/postcss-modules-local-by-default': 4.0.2
'@types/postcss-modules-scope': 3.0.4
dotenv: 16.6.1
icss-utils: 5.1.0(postcss@8.5.14)
less: 4.4.0
icss-utils: 5.1.0(postcss@8.5.26)
less: 4.9.0
lodash.camelcase: 4.3.0
postcss: 8.5.14
postcss-load-config: 3.1.4(postcss@8.5.14)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3))
postcss-modules-extract-imports: 3.0.0(postcss@8.5.14)
postcss-modules-local-by-default: 4.2.0(postcss@8.5.14)
postcss-modules-scope: 3.2.1(postcss@8.5.14)
postcss: 8.5.26
postcss-load-config: 3.1.4(postcss@8.5.26)(ts-node@10.9.1(@types/node@16.18.25)(typescript@5.9.3))
postcss-modules-extract-imports: 3.0.0(postcss@8.5.26)
postcss-modules-local-by-default: 4.2.0(postcss@8.5.26)
postcss-modules-scope: 3.2.1(postcss@8.5.26)
reserved-words: 0.1.2
sass: 1.97.3
source-map-js: 1.2.1
@@ -18519,7 +18557,7 @@ snapshots:
unist-util-stringify-position: 4.0.0
vfile-message: 4.0.2
vite-plugin-checker@0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3):
vite-plugin-checker@0.12.0(eslint@10.2.1(jiti@2.6.1))(meow@13.2.0)(optionator@0.9.4)(oxlint@1.69.0(oxlint-tsgolint@0.23.0))(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3):
dependencies:
'@babel/code-frame': 7.29.0
chokidar: 4.0.3
@@ -18528,7 +18566,7 @@ snapshots:
picomatch: 4.0.4
tiny-invariant: 1.3.3
tinyglobby: 0.2.15
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vscode-uri: 3.1.0
optionalDependencies:
eslint: 10.2.1(jiti@2.6.1)
@@ -18538,16 +18576,16 @@ snapshots:
stylelint: 17.7.0(typescript@5.9.3)
typescript: 5.9.3
vite-plugin-compression@0.5.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)):
vite-plugin-compression@0.5.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)):
dependencies:
chalk: 4.1.2
debug: 4.3.4
fs-extra: 10.1.0
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
vite-plugin-html@3.2.2(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)):
vite-plugin-html@3.2.2(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)):
dependencies:
'@rollup/pluginutils': 4.2.1
colorette: 2.0.20
@@ -18561,23 +18599,23 @@ snapshots:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite-plugin-image-optimizer@2.0.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(sharp@0.35.0)(svgo@4.0.2):
vite-plugin-image-optimizer@2.0.3(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(sharp@0.35.0)(svgo@4.0.2):
dependencies:
ansi-colors: 4.1.3
pathe: 2.0.3
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
optionalDependencies:
sharp: 0.35.0
svgo: 4.0.2
vite-tsconfig-paths@6.1.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(typescript@5.9.3):
vite-tsconfig-paths@6.1.1(rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4))(typescript@5.9.3):
dependencies:
debug: 4.3.4
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
vite: rolldown-vite@7.3.1(@types/node@16.18.25)(esbuild@0.28.1)(jiti@2.6.1)(less@4.9.0)(sass@1.97.3)(stylus@0.62.0)(terser@5.46.2)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
- typescript

View File

@@ -13,7 +13,16 @@ overrides:
'@babel/core@<=7.29.0': '>=7.29.6 <8'
# via: jest > babel-plugin-istanbul > @istanbuljs/load-nyc-config@1.1.0 (js-yaml ^3.13.1)
# remove: blocked — 1.1.0 is latest and still depends on js-yaml 3.x
'@istanbuljs/load-nyc-config>js-yaml': '>=4.2.0 <5'
'@istanbuljs/load-nyc-config>js-yaml': '>=4.3.1 <5'
# via: babel-plugin-istanbul > test-exclude@6 > minimatch@3.1.5 (^1.1.7); also glob@7
# remove: blocked — babel-plugin-istanbul pins test-exclude@6, which pins minimatch@3
brace-expansion@<1.1.18: '>=1.1.18 <2'
# via: jest > glob@10 > minimatch@9.0.9 (^2.0.1); also minimatch@5.1.9 (^2.0.1)
# remove: blocked — jest 30 resolves glob@10 > minimatch@9 internally
'brace-expansion@>=2.0.0 <2.1.4': '>=2.1.4 <3'
# via: eslint-plugin-sonarjs@4.0.2 (minimatch ^10.2.4) > minimatch@10.2.5 (^5.0.5)
# remove: blocked — minimatch@10.2.6 (latest) only widens to ^5.0.8, still vulnerable
'brace-expansion@>=5.0.0 <5.0.9': '>=5.0.9 <6'
# via: msw@1.3.2 (devDep) > cookie ^0.4.2
# remove: upgrade msw to >=2 (ships cookie ^1). Do NOT open the cap: cookie >=1 is
# ESM-only and breaks msw under jest's CJS sandbox (kills every test suite)
@@ -27,13 +36,26 @@ overrides:
# remove: blocked on rolldown-vite (7.3.1 is latest, still ^0.27.0);
# orval >=8.20.0 and ts-jest >=29.4.11 already fixed on their side
esbuild@>=0.27.3 <0.28.1: '>=0.28.1 <0.29.0'
# via: @commitlint/cli > @commitlint/config-validator > ajv@8 (fast-uri ^3.0.1)
# remove: blocked — ajv@8 caps fast-uri at ^3, and only 3.1.5 carries the fix
fast-uri@<3.1.5: '>=3.1.5 <4'
# via: direct devDep sass@1.97.3 (immutable ^5.0.2)
# remove: blocked — plain sass bumps stay within ^5, so the floor is what pulls 5.1.8
immutable@<5.1.8: '>=5.1.8 <6'
# via: react-use@17.5.1 (direct, js-cookie ^2.2.1); @grafana/data > react-use@17.6.0
# remove: bump react-use to >=17.6.1 (js-cookie ^3); @grafana/data side blocked
js-cookie@<=3.0.5: '>=3.0.7 <4'
# via: @orval/core@8.9.1 (devDep, js-yaml 4.1.1 EXACT pin — not deletable);
# json-schema-to-typescript@15 > @apidevtools/json-schema-ref-parser (^4.1.0)
# remove: upgrade orval to >=8.20.0 (drops js-yaml dependency entirely)
js-yaml@>=4.0.0 <=4.1.1: '>=4.2.0 <5'
'js-yaml@>=4.0.0 <4.3.1': '>=4.3.1 <5'
# via: typescript-plugin-css-modules@5.2.0 (less ^4.2.0) > less@4.4.0 (image-size ~0.5.0)
# remove: bump typescript-plugin-css-modules once it floors less itself; image-size has
# no patched release at all, so dropping the dep is the only fix — less@4.5.0 did
less@<4.5.0: '>=4.5.0 <5'
# via: direct dep postcss (nanoid ^3.3.17)
# remove: blocked — postcss 8.5.26 (latest) caps nanoid at ^3, fix landed in 3.3.18
nanoid@<3.3.18: '>=3.3.18 <4'
# via: react-syntax-highlighter@15.5.0 (prismjs ^1.27.0 + refractor@3 ~1.27.0 tilde-pinned)
# remove: bump react-syntax-highlighter to >=16.1.1 (prismjs ^1.30.0, refractor@5)
prismjs@<1.30.0: '>=1.30.0 <2'

View File

@@ -41,8 +41,7 @@
.ant-drawer-body {
display: flex;
flex-direction: column;
padding: 16px;
padding-bottom: 0;
padding: 0 16px;
}
.title {
@@ -79,6 +78,7 @@
align-items: center;
gap: 4px;
position: relative;
margin-top: 16px;
.log-body {
font-family: 'SF Mono';
@@ -123,6 +123,19 @@
}
}
.log-detail-drawer__section-divider {
height: 8px;
margin: 12px 0;
background-image:
radial-gradient(circle, var(--l3-border) 1px, transparent 1px),
radial-gradient(circle, var(--l3-border) 1px, transparent 1px);
background-size: 6px 2px;
background-position:
left top,
left bottom;
background-repeat: repeat-x;
}
.tabs-and-search {
display: flex;
justify-content: space-between;

View File

@@ -11,6 +11,13 @@ jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// DataViewer pulls in react-json-tree (ESM) + Monaco; mock it (as trace's tests
// do). These drawer tests assert the header/highlights, not the Overview body.
jest.mock('periscope/components/DataViewer', () => ({
__esModule: true,
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
}));
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
@@ -66,6 +73,12 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
expect(screen.getByTestId('log-details-header-next')).toBeInTheDocument();
});
it('renders the DataViewer in the Overview tab', () => {
renderDrawer();
expect(screen.getByTestId('overview-data-viewer')).toBeInTheDocument();
});
it('shows the log timestamp formatted (DASH_DATETIME) in the header', () => {
// Pin the timezone to UTC so the formatted output is deterministic across
// machines/CI (Jest doesn't fix a TZ).

View File

@@ -402,6 +402,8 @@ function LogDetailInner({
{isLogDetailsV2 && <LogHighlights log={log} />}
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
@@ -418,15 +420,21 @@ function LogDetailInner({
</div>
),
},
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
// JSON tab is redundant.
...(isLogDetailsV2
? []
: [
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
]),
{
value: VIEW_TYPES.CONTEXT,
label: (
@@ -509,7 +517,7 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && (
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}

View File

@@ -107,14 +107,38 @@ async function renderAndFocus(
return editor;
}
function openRecents(): Promise<void> {
// Re-requests completions while waiting: typing and the async fetches can close the popup.
function waitForRecents(
assertLabels: (labels: string[]) => void,
): Promise<void> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
expect(getRecentLabels().length).toBeGreaterThan(0);
assertLabels(getRecentLabels());
},
{ timeout: 3000 },
);
}
function openRecents(): Promise<void> {
return waitForRecents((labels) => expect(labels.length).toBeGreaterThan(0));
}
function waitForPopupElement(
find: () => HTMLElement | null | undefined,
): Promise<HTMLElement> {
return waitFor(
() => {
const view = getEditorView();
if (view && !isCompletionOpen()) {
startCompletion(view);
}
const node = find();
expect(node).toBeTruthy();
return node as HTMLElement;
},
{ timeout: 3000 },
);
@@ -132,11 +156,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([FRONTEND_FILTER]),
);
const view = getEditorView() as EditorView;
@@ -152,11 +173,8 @@ describe('QuerySearch recent searches', () => {
await openRecents();
await userEvent.type(editor, 'status_code');
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([STATUS_CODE_FILTER]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([STATUS_CODE_FILTER]),
);
});
@@ -170,11 +188,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([FRONTEND_FILTER]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([FRONTEND_FILTER]),
);
});
@@ -187,11 +202,8 @@ describe('QuerySearch recent searches', () => {
await openRecents();
await userEvent.type(editor, FRONTEND_FILTER);
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual([supersetFilter]);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual([supersetFilter]),
);
});
@@ -206,11 +218,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
await waitFor(
() => {
expect(getRecentLabels()).toStrictEqual(expectedLabels);
},
{ timeout: 3000 },
await waitForRecents((labels) =>
expect(labels).toStrictEqual(expectedLabels),
);
});
@@ -221,17 +230,13 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus(onChange);
await openRecents();
const option = await waitFor(
() => {
const node = Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER);
expect(node).toBeDefined();
return node as HTMLElement;
},
{ timeout: 3000 },
const option = await waitForPopupElement(() =>
Array.from(
document.querySelectorAll<HTMLElement>(COMPLETION_LABEL_SELECTOR),
).find((element) => element.textContent === FRONTEND_FILTER),
);
await userEvent.click(option);
// fireEvent: userEvent's pointerdown blurs the editor, closing the popup before CM's mousedown apply.
fireEvent.mouseDown(option);
await waitFor(
() => {
@@ -256,13 +261,8 @@ describe('QuerySearch recent searches', () => {
await renderAndFocus();
await openRecents();
const deleteButton = await waitFor(
() => {
const button = document.querySelector(DELETE_BUTTON_SELECTOR);
expect(button).toBeInTheDocument();
return button as HTMLElement;
},
{ timeout: 3000 },
const deleteButton = await waitForPopupElement(() =>
document.querySelector<HTMLElement>(DELETE_BUTTON_SELECTOR),
);
// fireEvent: the button preventDefaults pointerdown, which makes userEvent.click drop the mouse chain.

View File

@@ -1,4 +1,7 @@
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
@@ -18,17 +21,24 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
serviceAccountRoles: [
{
id: 'sar-admin-1',
serviceAccountId: 'sa-1',
roleId: managedRoles[0].id,
role: { ...managedRoles[0], transactionGroups: [] },
},
],
};
function renderDrawer(
@@ -58,22 +68,13 @@ function setupBaseHandlers(): void {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
);
}

View File

@@ -1,4 +1,7 @@
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import {
listRolesSuccessResponse,
managedRoles,
} from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -10,23 +13,33 @@ const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const SA_ROLES_ENDPOINT = '*/api/v1/service_account_roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_account_roles/:id';
const ADMIN_ASSIGNMENT_ID = 'sar-admin-1';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
serviceAccountRoles: [
{
id: ADMIN_ASSIGNMENT_ID,
serviceAccountId: 'sa-1',
roleId: managedRoles[0].id,
role: { ...managedRoles[0], transactionGroups: [] },
},
],
};
const deletedAccountResponse = {
...activeAccountResponse,
id: 'sa-2',
status: 'DELETED',
serviceAccountRoles: [],
};
function renderDrawer(
@@ -58,22 +71,13 @@ describe('ServiceAccountDrawer', () => {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
setupAuthzAdmin(),
);
});
@@ -136,11 +140,14 @@ describe('ServiceAccountDrawer', () => {
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(ctx.status(204));
}),
);
@@ -159,7 +166,8 @@ describe('ServiceAccountDrawer', () => {
await waitFor(() => {
expect(roleSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: '019c24aa-2248-7585-a129-4188b3473c27',
serviceAccountId: 'sa-1',
roleId: '019c24aa-2248-7585-a129-4188b3473c27',
}),
);
expect(deleteSpy).not.toHaveBeenCalled();
@@ -174,11 +182,14 @@ describe('ServiceAccountDrawer', () => {
server.use(
rest.post(SA_ROLES_ENDPOINT, async (req, res, ctx) => {
roleSpy(await req.json());
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
}),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => {
deleteSpy();
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
rest.delete(SA_ROLE_DELETE_ENDPOINT, (req, res, ctx) => {
deleteSpy(req.params.id);
return res(ctx.status(204));
}),
);
@@ -198,7 +209,7 @@ describe('ServiceAccountDrawer', () => {
await user.click(saveBtn);
await waitFor(() => {
expect(deleteSpy).toHaveBeenCalled();
expect(deleteSpy).toHaveBeenCalledWith(ADMIN_ASSIGNMENT_ID);
expect(roleSpy).not.toHaveBeenCalled();
});
});
@@ -246,9 +257,6 @@ describe('ServiceAccountDrawer', () => {
rest.get('*/api/v1/service_accounts/sa-2/keys', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get('*/api/v1/service_accounts/sa-2/roles', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
);
renderDrawer({ account: 'sa-2' });
@@ -312,22 +320,13 @@ describe('ServiceAccountDrawer save-error UX', () => {
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) => res(ctx.status(204))),
setupAuthzAdmin(),
);
});
@@ -410,14 +409,17 @@ describe('ServiceAccountDrawer save-error UX', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let roleAddCallCount = 0;
// First call → 429, second call → 200
// First call → 429, second call → 201
server.use(
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) => {
roleAddCallCount += 1;
if (roleAddCallCount === 1) {
return res(ctx.status(429), ctx.json({ message: 'Too Many Requests' }));
}
return res(ctx.status(200), ctx.json({ status: 'success', data: {} }));
return res(
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'sar-new' } }),
);
}),
);

View File

@@ -51,4 +51,5 @@
gap: var(--spacing-3);
max-width: 500px;
padding: 24px;
max-height: 100%;
}

View File

@@ -2806,8 +2806,8 @@ export const hostWidgetInfo = [
'The 1m, 5m and 15m load averages together; 1m above 15m means load is building.',
},
{
title: 'Network usage (bytes)',
yAxisUnit: 'bytes',
title: 'Network usage',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-bytes',
description:
'Throughput in bytes/s per interface and direction, to spot NICs nearing rated bandwidth.',
@@ -2841,8 +2841,8 @@ export const hostWidgetInfo = [
'Active connection counts per protocol and state (ESTABLISHED, TIME_WAIT, SYN_RECV) to spot leaks and churn.',
},
{
title: 'System disk io (bytes transferred)',
yAxisUnit: 'bytes',
title: 'System disk IO',
yAxisUnit: 'binBps',
docPath: '/infrastructure-monitoring/host-monitoring/#system-disk-io-bytes',
description:
'Disk throughput in bytes/s per device and direction, tracking heavy file I/O or database flushes.',

View File

@@ -1,4 +1,21 @@
.overview-container {
.data-viewer {
min-height: 500px;
}
.pretty-view__search-wrapper {
background: var(--l2-background);
}
.pretty-view__search-input {
background: var(--l2-background) !important;
}
.log-body-value {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);

View File

@@ -13,15 +13,28 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
import { isLogDetailsV2 } from 'components/LogDetail/constants';
import { DataViewer } from 'periscope/components/DataViewer';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import { useLogAttributeActions } from './hooks/useLogAttributeActions';
import TableView from './TableView';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import {
aggregateAttributesResourcesToObject,
getBodyDisplayString,
getSanitizedLogBody,
parseJsonStringBody,
removeEscapeCharacters,
} from './utils';
import './Overview.styles.scss';
// Skip body sanitization above this size. sanitization is expensive and fails
// for large bodies
const MAX_BODY_SANITIZE_CHARS = 64 * 1024;
interface OverviewProps {
logData: ILog;
isListViewPanel?: boolean;
@@ -51,6 +64,56 @@ function Overview({
const isDarkMode = useIsDarkMode();
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
});
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = Object.fromEntries(
Object.entries({ ...raw, body: parseJsonStringBody(raw.body) }).filter(
([, value]) => value !== undefined,
),
);
return (
<div className="overview-container">
<DataViewer
data={prettyData}
drawerKey="logs-details"
fontSize={13}
prettyViewProps={{
actions,
visibleActions,
renderLeafValue: (value, keyPath): ReactNode | undefined => {
// Sanitize (unescape + ANSI→color) string values under `body`.
// Skip huge ones (render raw, still safe) to avoid the sanitize
// choke;
if (
typeof value !== 'string' ||
keyPath[keyPath.length - 1] !== 'body' ||
value.length > MAX_BODY_SANITIZE_CHARS
) {
return undefined;
}
return (
<span
className="log-body-value"
// Safe: getSanitizedLogBody runs the value through dompurify.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
}}
/>
);
},
}}
jsonString={JSON.stringify(raw, null, 2)}
/>
</div>
);
}
const options: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,

View File

@@ -0,0 +1,13 @@
export enum LogAttributeBucket {
ATTRIBUTES = 'attributes',
RESOURCES = 'resources',
SCOPE = 'scope',
}
export enum LogDetailsAction {
COPY = 'copy',
FILTER_IN = 'filter-in',
FILTER_OUT = 'filter-out',
GROUP_BY = 'group-by',
REPLACE_FILTER = 'replace-filter',
}

View File

@@ -0,0 +1,232 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import {
FieldContext,
PrettyViewAction,
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useAppContext } from 'providers/App/App';
import { LogDetailsAction } from '../constants';
import {
buildLogFilterTarget,
getFilterQueryData,
getGroupByQueryData,
getReplaceFilterQueryData,
} from '../logAttributeActions.utils';
interface UseLogAttributeActionsParams {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
}
interface UseLogAttributeActionsResult {
actions: PrettyViewAction[];
visibleActions: VisibleActionsConfig;
}
const COPY_ONLY_ACTIONS = [LogDetailsAction.COPY];
const ALL_LEAF_ACTIONS = [
LogDetailsAction.COPY,
LogDetailsAction.FILTER_IN,
LogDetailsAction.FILTER_OUT,
LogDetailsAction.GROUP_BY,
LogDetailsAction.REPLACE_FILTER,
];
/**
* PrettyView filter/group-by/replace actions for the log-details drawer (keys mapped via
* buildLogFilterTarget). Also owns `visibleActions` (leaf/nested + list-panel copy-only).
*/
export function useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel = false,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isOldExplorerOrLive =
pathname === ROUTES.OLD_LOGS_EXPLORER || pathname === ROUTES.LIVE_LOGS;
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
const operator = isFilterIn
? target.filterInOperator
: target.filterOutOperator;
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) =>
index === 0
? getFilterQueryData(item, target, context.fieldValue, operator)
: item,
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const groupBy = useCallback(
(context: FieldContext): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
if (!target.groupBySupported || !target.groupByKey) {
return;
}
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) => (index === 0 ? getGroupByQueryData(item, target) : item),
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const replaceFilter = useCallback(
(context: FieldContext): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) =>
index === 0
? getReplaceFilterQueryData(item, target, context.fieldValue)
: item,
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const actions: PrettyViewAction[] = useMemo(() => {
const isRestricted = (fieldKeyPath: (string | number)[]): boolean =>
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.isRestricted;
return [
{
key: LogDetailsAction.FILTER_IN,
label: 'Filter for value',
icon: <CirclePlus size={12} />,
onClick: (context): void => filterFor(context, true),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.FILTER_OUT,
label: 'Filter out value',
icon: <CircleMinus size={12} />,
onClick: (context): void => filterFor(context, false),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.GROUP_BY,
label: 'Group by field',
icon: <Layers size={12} />,
onClick: groupBy,
shouldHide: (_key, fieldKeyPath): boolean =>
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported || isOldExplorerOrLive,
},
{
key: LogDetailsAction.REPLACE_FILTER,
label: 'Replace filters with this value',
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
},
];
}, [
filterFor,
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
]);
const visibleActions = useMemo<VisibleActionsConfig>(
() => ({
leaf: isListViewPanel ? COPY_ONLY_ACTIONS : ALL_LEAF_ACTIONS,
nested: COPY_ONLY_ACTIONS,
}),
[isListViewPanel],
);
return { actions, visibleActions };
}

View File

@@ -0,0 +1,198 @@
import { MetricsType } from 'container/MetricsApplication/constant';
import {
buildLogFilterTarget,
toTypedFilterValue,
} from './logAttributeActions.utils';
describe('buildLogFilterTarget', () => {
describe('attributes / resources / scope / top-level scalars', () => {
it('maps a top-level scalar to its bare key with =/!=, groupable', () => {
const t = buildLogFilterTarget(['severity_text'], 'ERROR', true);
expect(t).toMatchObject({
fieldKey: 'severity_text',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: true,
groupByKey: 'severity_text',
isRestricted: false,
});
expect(t.metricsType).toBeUndefined();
});
it('strips the `attributes` root to a bare dotted key + Tag type', () => {
expect(
buildLogFilterTarget(['attributes', 'http.method'], 'GET', true),
).toMatchObject({
fieldKey: 'http.method',
filterInOperator: '=',
metricsType: MetricsType.Tag,
groupBySupported: true,
});
});
it('maps `resources` with Resource type', () => {
expect(
buildLogFilterTarget(['resources', 'service.name'], 'api', true),
).toMatchObject({
fieldKey: 'service.name',
metricsType: MetricsType.Resource,
});
});
it('maps `scope` with Scope type', () => {
expect(
buildLogFilterTarget(['scope', 'name'], 'my-scope', true),
).toMatchObject({ fieldKey: 'name', metricsType: MetricsType.Scope });
});
it('offers group-by for attributes now', () => {
expect(
buildLogFilterTarget(['attributes', 'k'], 'v', true).groupBySupported,
).toBe(true);
});
});
describe('restricted fields (timestamp / id)', () => {
it.each(['timestamp', 'id'])(
'marks %s restricted with no group-by',
(key) => {
const t = buildLogFilterTarget([key], 'v', true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
},
);
});
describe('body scalars', () => {
it('maps a top-level body scalar to body.<key> with =/!=, groupable when json body on', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', true);
expect(t).toMatchObject({
fieldKey: 'body.message',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: true,
groupByKey: 'body.message',
isRestricted: false,
});
expect(t.dataType).toBeDefined();
expect(t.metricsType).toBeUndefined();
});
it('maps a nested body scalar to a dotted body key', () => {
expect(buildLogFilterTarget(['body', 'a', 'b'], 'x', true)).toMatchObject({
fieldKey: 'body.a.b',
groupBySupported: true,
groupByKey: 'body.a.b',
});
});
it('does not offer group-by when USE_JSON_BODY is off', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', false);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
expect(t.fieldKey).toBe('body.message');
});
it('filters the whole `body` field when body is an unparsed string leaf', () => {
expect(buildLogFilterTarget(['body'], 'raw text', true)).toMatchObject({
fieldKey: 'body',
filterInOperator: '=',
groupBySupported: false,
});
});
it('restricts a body leaf named `timestamp` (no filter / group-by)', () => {
const t = buildLogFilterTarget(['body', 'timestamp'], '2026-01-01', true);
expect(t.fieldKey).toBe('body.timestamp');
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it('restricts a nested body leaf named `timestamp`', () => {
const t = buildLogFilterTarget(['body', 'obj', 'timestamp'], 'x', true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
});
it('restricts a body leaf named `id` (uses RESTRICTED_SELECTED_FIELDS)', () => {
expect(buildLogFilterTarget(['body', 'id'], 'abc', true).isRestricted).toBe(
true,
);
});
it('does not restrict an ordinary body leaf', () => {
expect(
buildLogFilterTarget(['body', 'message'], 'hello', true).isRestricted,
).toBe(false);
});
});
describe('body arrays', () => {
it('uses has()/!has() on the array key for a primitive array element', () => {
const t = buildLogFilterTarget(['body', 'tags', 0], 'urgent', true);
expect(t).toMatchObject({
fieldKey: 'body.tags',
filterInOperator: 'has',
groupBySupported: false,
});
expect(t.filterOutOperator).toContain('has');
expect(t.filterOutOperator).not.toBe('has');
});
it('collapses deep array-element paths to a []-marked has() key', () => {
expect(
buildLogFilterTarget(
['body', 'config', 'features', 1, 'items', 0, 'variants', 2],
'ballpen',
true,
),
).toMatchObject({
fieldKey: 'body.config.features[].items[].variants',
filterInOperator: 'has',
});
});
it('maps a field nested inside an array element with =/!= and a []-marked key, no group-by', () => {
expect(
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', true),
).toMatchObject({
fieldKey: 'body.items[].sku',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: false,
});
});
it('uses the [*] string-body marker for an array element when USE_JSON_BODY is off', () => {
expect(
buildLogFilterTarget(['body', 'tags', 0], 'urgent', false),
).toMatchObject({ fieldKey: 'body.tags[*]', filterInOperator: 'has' });
});
it('uses [*] for a field nested inside an array element when USE_JSON_BODY is off', () => {
expect(
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', false),
).toMatchObject({ fieldKey: 'body.items[*].sku', filterInOperator: '=' });
});
});
});
describe('toTypedFilterValue', () => {
const run = (value: unknown): unknown => toTypedFilterValue(value);
it('keeps numbers/booleans as their JS type (so the expression stays unquoted)', () => {
expect(run(848)).toBe(848);
expect(typeof run(848)).toBe('number');
expect(run(1.1)).toBe(1.1);
expect(run(true)).toBe(true);
expect(typeof run(true)).toBe('boolean');
});
it('passes strings through unchanged (no numeric inference)', () => {
expect(run('unknown_service')).toBe('unknown_service');
expect(typeof run('12345')).toBe('string');
});
});

View File

@@ -0,0 +1,229 @@
import { v4 as uuid } from 'uuid';
import {
negateOperator,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogAttributeBucket } from './constants';
import { generateFieldKeyForArray, getDataTypes } from './utils';
export const toTypedFilterValue = (value: unknown): string =>
typeof value === 'number' || typeof value === 'boolean'
? (value as unknown as string)
: String(value);
export interface LogFilterTarget {
fieldKey: string;
filterInOperator: string;
filterOutOperator: string;
dataType?: DataTypes;
metricsType?: MetricsType;
groupBySupported: boolean;
groupByKey?: string;
isRestricted: boolean;
}
// Collapse a body forward path into the query-builder key segment; array indices become
// `[]` (json body on) or `[*]` (string body off — a distinct operator/search path).
// ['items', 2, 'sku'] -> 'items[].sku' (json on) / 'items[*].sku' (off)
// ['tags', 0] -> 'tags[]' (json on) / 'tags[*]' (off)
const collapseBodyPath = (
subpath: (string | number)[],
isBodyJsonQueryEnabled: boolean,
): string => {
const arrayMarker = isBodyJsonQueryEnabled ? '[]' : '[*]';
let out = '';
subpath.forEach((seg) => {
if (typeof seg === 'number') {
out += arrayMarker;
} else {
out += out ? `.${seg}` : seg;
}
});
return out;
};
const metricsTypeForRoot = (root: string | number): MetricsType | undefined => {
if (root === LogAttributeBucket.ATTRIBUTES) {
return MetricsType.Tag;
}
if (root === LogAttributeBucket.RESOURCES) {
return MetricsType.Resource;
}
if (root === LogAttributeBucket.SCOPE) {
return MetricsType.Scope;
}
return undefined;
};
/**
* Map a PrettyView leaf (forward keyPath) to its query-builder filter/group-by target:
* scalars → `=`/`!=`, body array elements → `has`/`!has`; group-by gated by
* `groupBySupported`. Attribute/resource/scope carry a `metricsType` (Tag/Resource/Scope).
*/
export const buildLogFilterTarget = (
fieldKeyPath: (string | number)[],
value: unknown,
isBodyJsonQueryEnabled: boolean,
): LogFilterTarget => {
const root = fieldKeyPath[0];
// Attributes / resources / scope / top-level scalars: bare dotted key, =/!=.
if (root !== 'body') {
const fieldKey =
fieldKeyPath.length > 1 ? fieldKeyPath.slice(1).join('.') : String(root);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(fieldKey);
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
metricsType: metricsTypeForRoot(root),
groupBySupported: !isRestricted,
groupByKey: isRestricted ? undefined : fieldKey,
isRestricted,
};
}
const subpath = fieldKeyPath.slice(1);
// Whole-body leaf (unparsed string body): filter on the `body` field itself.
if (subpath.length === 0) {
return {
fieldKey: 'body',
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
groupBySupported: false,
isRestricted: false,
};
}
const collapsed = collapseBodyPath(subpath, isBodyJsonQueryEnabled);
const isArrayElement = typeof subpath[subpath.length - 1] === 'number';
if (isArrayElement) {
// has(body.<array>, value): generateFieldKeyForArray strips the trailing value
// segment + `[]` exactly as the old BodyTitleRenderer.filterHandler did.
const fieldKey = generateFieldKeyForArray(
`${collapsed}.${String(value)}`,
getDataTypes(value),
isBodyJsonQueryEnabled,
);
return {
fieldKey,
filterInOperator: QUERY_BUILDER_FUNCTIONS.HAS,
filterOutOperator: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
dataType: getDataTypes([value]),
groupBySupported: false,
isRestricted: false,
};
}
const fieldKey = `body.${collapsed}`;
// Restrict body leaves whose own key is a restricted field (e.g. a JSON body's own
// `timestamp`/`id`/`date`) — hides filter / group-by, same as top-level fields.
const leafKey = String(subpath[subpath.length - 1]);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(leafKey);
// Group by only for plain body scalars (no array anywhere in the path) with json
// body on — mirrors isGroupBySupported in the old BodyTitleRenderer.
const groupBySupported =
isBodyJsonQueryEnabled && !collapsed.includes('[]') && !isRestricted;
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
groupBySupported,
groupByKey: groupBySupported ? fieldKey : undefined,
isRestricted,
};
};
const normalizeDataType = (
dataType: DataTypes | undefined,
): DataTypes | undefined =>
dataType && Object.values(DataTypes).includes(dataType) ? dataType : undefined;
// Append a filter item (filter-in / filter-out) to a query-data item. The autocomplete
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
export const getFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
value: unknown,
operator: string,
): IBuilderQuery => {
const filterKey = chooseAutocompleteFromCustomValue(
[],
target.fieldKey,
target.dataType,
target.metricsType,
);
return {
...item,
filters: {
items: [
...(item.filters?.items || []),
{
id: uuid(),
key: filterKey,
op: getOperatorValue(operator),
value: toTypedFilterValue(value),
},
],
op: item.filters?.op || 'AND',
},
};
};
// Append a group-by. Caller guards groupBySupported / groupByKey.
export const getGroupByQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
): IBuilderQuery => {
const newGroupByItem: BaseAutocompleteData = {
key: target.groupByKey || '',
type: target.metricsType || '',
dataType: normalizeDataType(target.dataType),
};
return { ...item, groupBy: [...(item.groupBy || []), newGroupByItem] };
};
// Replace all filters with a single IN filter on this value.
export const getReplaceFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
value: unknown,
): IBuilderQuery => {
const newFilterItem: BaseAutocompleteData = {
key: target.fieldKey,
type: target.metricsType || '',
dataType: normalizeDataType(target.dataType),
};
return {
...item,
filters: {
items: [
{
id: '',
key: newFilterItem,
op: QUERY_BUILDER_OPERATORS.IN,
value: [toTypedFilterValue(value)],
},
],
op: 'AND',
},
filter: { expression: '' },
};
};

View File

@@ -1,12 +1,87 @@
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
aggregateAttributesResourcesToObject,
flattenObject,
getDataTypes,
getSanitizedLogBody,
parseJsonStringBody,
recursiveParseJSON,
} from './utils';
describe('parseJsonStringBody', () => {
it('parses a JSON-object string into an object', () => {
expect(parseJsonStringBody('{"a":1,"b":{"c":2}}')).toStrictEqual({
a: 1,
b: { c: 2 },
});
});
it('parses a JSON-array string into an array', () => {
expect(parseJsonStringBody('[1,2,3]')).toStrictEqual([1, 2, 3]);
});
it('returns a plain (non-JSON) string unchanged', () => {
expect(parseJsonStringBody('plain log line')).toBe('plain log line');
});
it('returns a string that is not object/array-looking unchanged', () => {
expect(parseJsonStringBody('42')).toBe('42');
});
it('returns an invalid JSON string unchanged', () => {
expect(parseJsonStringBody('{not valid}')).toBe('{not valid}');
});
it('returns an already-object body unchanged (same reference)', () => {
const body = { message: 'hi', a: 1 };
expect(parseJsonStringBody(body)).toBe(body);
});
it('leaves a body larger than the 128KB parse guard as a string', () => {
const huge = `{"x":"${'a'.repeat(130 * 1024)}"}`;
expect(parseJsonStringBody(huge)).toBe(huge);
});
});
describe('aggregateAttributesResourcesToObject', () => {
const mockLog = {
id: 'log-1',
timestamp: 1234,
body: 'hello',
severity_text: 'INFO',
severity_number: 9,
attributes_string: { 'http.method': 'GET' },
attributes_int: { retries: 3 },
resources_string: { 'service.name': 'cart' },
scope_string: { lib: 'otel' },
} as unknown as ILog;
it('merges attributes_/resources_/scope_ maps and keeps scalars + body', () => {
const result = aggregateAttributesResourcesToObject(mockLog);
expect(result.attributes).toStrictEqual({
'http.method': 'GET',
retries: 3,
});
expect(result.resources).toStrictEqual({ 'service.name': 'cart' });
expect(result.scope).toStrictEqual({ lib: 'otel' });
expect(result.body).toBe('hello');
expect(result.id).toBe('log-1');
expect(result.severity_text).toBe('INFO');
});
it('does not parse a JSON-string body (leaves it raw)', () => {
const result = aggregateAttributesResourcesToObject({
...mockLog,
body: '{"a":1}',
} as unknown as ILog);
expect(result.body).toBe('{"a":1}');
});
});
describe('recursiveParseJSON', () => {
it('should return an empty object if the input is not valid JSON', () => {
const result = recursiveParseJSON('not valid JSON');

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import Convert from 'ansi-to-html';
import type { DataNode } from 'antd/es/tree';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
@@ -262,10 +263,11 @@ export const filterKeyForField = (field: string): string => {
return fieldAttribs?.newField || field;
};
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
export const aggregateAttributesResourcesToObject = (
logData: ILog,
): ILogAggregateAttributesResources => {
const outputJson: ILogAggregateAttributesResources = {
body: logData.body,
date: logData.date,
id: logData.id,
severityNumber: logData.severityNumber,
severityText: logData.severityText,
@@ -281,6 +283,9 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
};
Object.keys(logData).forEach((key) => {
if (key === 'date') {
return;
}
if (key.startsWith('attributes_')) {
outputJson.attributes = outputJson.attributes || {};
Object.assign(outputJson.attributes, logData[key as keyof ILog]);
@@ -291,12 +296,47 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
outputJson.scope = outputJson.scope || {};
Object.assign(outputJson.scope, logData[key as keyof ILog]);
} else {
// @ts-expect-error
// @ts-expect-error dynamic top-level copy
outputJson[key] = logData[key as keyof ILog];
}
});
return JSON.stringify(outputJson, null, 2);
// Show `timestamp` first and `id` last in the details view.
const { timestamp, id, ...rest } = outputJson;
return { timestamp, ...rest, id };
};
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
try {
return JSON.stringify(aggregateAttributesResourcesToObject(logData), null, 2);
} catch (err) {
Sentry.captureException(err);
return '';
}
};
const MAX_JSON_BODY_PARSE_BYTES = 128 * 1024;
// A JSON-encoded object/array `body` is parsed so DataViewer renders it as a
// tree instead of one escaped string; plain-text bodies are returned unchanged.
// Guarded against very large payloads.
export const parseJsonStringBody = (body: ILog['body']): ILog['body'] => {
if (typeof body !== 'string') {
return body;
}
const trimmed = body.trim();
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[');
if (!looksLikeJson || trimmed.length > MAX_JSON_BODY_PARSE_BYTES) {
return body;
}
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === 'object'
? (parsed as ILogBody)
: body;
} catch {
return body;
}
};
const isFloat = (num: number): boolean => num % 1 !== 0;

View File

@@ -2,14 +2,17 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, Select, Space } from 'antd';
import { FeatureKeys } from 'constants/features';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useAppContext } from 'providers/App/App';
import { ProcessorData } from 'types/api/pipeline/def';
import { formValidationRules } from '../config';
import { processorFields, ProcessorFormField } from './config';
import { ProcessorFormField } from './config';
import CSVInput from './FormFields/CSVInput';
import JsonFlattening from './FormFields/JsonFlattening';
import { FormWrapper, PipelineIndexIcon, StyledSelect } from './styles';
import { resolveProcessorFields } from './utils';
import './styles.scss';
@@ -133,16 +136,23 @@ function ProcessorForm({
selectedProcessorData,
isAdd,
}: ProcessorFormProps): JSX.Element {
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return (
<div className="processor-form-container">
{processorFields[processorType]?.map((fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
))}
{resolveProcessorFields(processorType, isBodyJsonEnabled).map(
(fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
),
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { processorFields, ProcessorFormField } from './config';
const BODY_PARSE_FROM = 'body';
const JSON_BODY_PARSE_FROM = 'body.message';
// With use_json_body the collector normalizes every body into a map before user
// operators run, so a parser pointed at `body` gets a map it cannot read and
// silently extracts nothing. The log text lives at body.message.
export function resolveProcessorFields(
processorType: string,
isBodyJsonEnabled: boolean,
): Array<ProcessorFormField> {
const fields = processorFields[processorType] ?? [];
if (!isBodyJsonEnabled) {
return fields;
}
return fields.map((field) =>
field.name === 'parse_from' && field.initialValue === BODY_PARSE_FROM
? { ...field, initialValue: JSON_BODY_PARSE_FROM }
: field,
);
}

View File

@@ -0,0 +1,45 @@
import { processorFields } from '../PipelineListsView/AddNewProcessor/config';
import { resolveProcessorFields } from '../PipelineListsView/AddNewProcessor/utils';
const parseFromDefault = (
fields: ReturnType<typeof resolveProcessorFields>,
): unknown => fields.find((field) => field.name === 'parse_from')?.initialValue;
describe('resolveProcessorFields', () => {
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'defaults %s parse_from to body.message when use_json_body is on',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, true))).toBe(
'body.message',
);
},
);
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'keeps %s parse_from as body when use_json_body is off',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, false))).toBe(
'body',
);
},
);
it('leaves parse_from defaults that do not point at the body alone', () => {
expect(parseFromDefault(resolveProcessorFields('time_parser', true))).toBe(
'attributes.timestamp',
);
expect(
parseFromDefault(resolveProcessorFields('severity_parser', true)),
).toBe('attributes.logLevel');
});
it('does not mutate the shared config', () => {
resolveProcessorFields('grok_parser', true);
expect(parseFromDefault(processorFields.grok_parser)).toBe('body');
});
it('returns an empty list for an unknown processor type', () => {
expect(resolveProcessorFields('does_not_exist', true)).toStrictEqual([]);
});
});

View File

@@ -10,7 +10,6 @@ import ServiceAccountsSettings from '../ServiceAccountsSettings';
const SA_LIST_ENDPOINT = '*/api/v1/service_accounts';
const SA_ENDPOINT = '*/api/v1/service_accounts/:id';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockServiceAccountsAPI = [
@@ -18,7 +17,7 @@ const mockServiceAccountsAPI = [
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
serviceAccountRoles: [],
status: 'ACTIVE',
createdAt: 1700000000,
updatedAt: 1700000001,
@@ -27,7 +26,7 @@ const mockServiceAccountsAPI = [
id: 'sa-2',
name: 'Monitoring Agent',
email: 'monitor@signoz.io',
roles: ['signoz-viewer'],
serviceAccountRoles: [],
status: 'ACTIVE',
createdAt: 1700000002,
updatedAt: 1700000003,
@@ -36,7 +35,7 @@ const mockServiceAccountsAPI = [
id: 'sa-3',
name: 'Legacy Bot',
email: 'legacy@signoz.io',
roles: ['signoz-editor'],
serviceAccountRoles: [],
status: 'DISABLED',
createdAt: 1700000004,
updatedAt: 1700000005,
@@ -61,9 +60,6 @@ describe('ServiceAccountsSettings (integration)', () => {
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),

View File

@@ -1,19 +1,23 @@
import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRoleDeprecated,
useDeleteServiceAccountRoleDeprecated,
useGetServiceAccountRoles,
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useGetServiceAccount,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
AuthtypesGettableRoleDTO,
ServiceaccounttypesServiceAccountRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
Fulfilled = 'fulfilled',
Rejected = 'rejected',
}
// Stable identity so the memos below do not recompute on every render.
const EMPTY_SERVICE_ACCOUNT_ROLES: ServiceaccounttypesServiceAccountRoleDTO[] =
[];
export interface RoleUpdateFailure {
roleName: string;
error: unknown;
@@ -33,34 +37,40 @@ export function useServiceAccountRoleManager(
accountId: string,
options?: { enabled?: boolean },
): UseServiceAccountRoleManagerResult {
const queryClient = useQueryClient();
const { data, isLoading } = useGetServiceAccountRoles(
const { data, isLoading } = useGetServiceAccount(
{ id: accountId },
{ query: { enabled: options?.enabled ?? true } },
);
const serviceAccountRoles =
data?.data?.serviceAccountRoles ?? EMPTY_SERVICE_ACCOUNT_ROLES;
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => data?.data ?? [],
[data?.data],
() =>
serviceAccountRoles.map((serviceAccountRole) => serviceAccountRole.role),
[serviceAccountRoles],
);
// DELETE /api/v1/service_account_roles/{id} is keyed by the join row, not the role.
const assignmentIdByRoleId = useMemo(
() =>
new Map(
serviceAccountRoles.map((serviceAccountRole) => [
serviceAccountRole.roleId,
serviceAccountRole.id,
]),
),
[serviceAccountRoles],
);
// the retry for these mutations is safe due to being idempotent on backend
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
const { mutateAsync: createRole } = useCreateServiceAccountRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
queryClient.invalidateQueries(
getGetServiceAccountRolesQueryKey({ id: accountId }),
),
[accountId, queryClient],
);
const applyDiff = useCallback(
async (
localRoleIds: string[],
@@ -84,26 +94,34 @@ export function useServiceAccountRoleManager(
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof createRole> =>
createRole({ pathParams: { id: accountId }, data: { id: role.id } }),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof deleteRole> =>
deleteRole({ pathParams: { id: accountId, rid: role.id ?? '' } }),
createRole({
data: { serviceAccountId: accountId, roleId: role.id ?? '' },
}),
})),
...removedRoles
.map((role) => ({
role,
assignmentId: assignmentIdByRoleId.get(role.id ?? ''),
}))
.filter(
(
entry,
): entry is {
role: AuthtypesGettableRoleDTO;
assignmentId: string;
} => !!entry.assignmentId,
)
.map(({ role, assignmentId }) => ({
role,
run: (): ReturnType<typeof deleteRole> =>
deleteRole({ pathParams: { id: assignmentId } }),
})),
];
const results = await Promise.allSettled(
allOperations.map((op) => op.run()),
);
const successCount = results.filter(
(r) => r.status === PromiseStatus.Fulfilled,
).length;
if (successCount > 0) {
await invalidateRoles();
}
const failures: RoleUpdateFailure[] = [];
results.forEach((result, index) => {
if (result.status === PromiseStatus.Rejected) {
@@ -113,7 +131,6 @@ export function useServiceAccountRoleManager(
error: result.reason,
onRetry: async (): Promise<void> => {
await run();
await invalidateRoles();
},
});
}
@@ -121,7 +138,7 @@ export function useServiceAccountRoleManager(
return failures;
},
[accountId, currentRoles, createRole, deleteRole, invalidateRoles],
[accountId, currentRoles, assignmentIdByRoleId, createRole, deleteRole],
);
return {

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { CSSProperties, useMemo, useState } from 'react';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import logEvent from 'api/common/logEvent';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
@@ -24,16 +24,23 @@ export interface DataViewerProps {
data: Record<string, any>;
drawerKey?: string;
prettyViewProps?: Omit<PrettyViewProps, 'data' | 'drawerKey'>;
// Optional override for the JSON view otherwise `data` is
// stringified and used.
jsonString?: string;
fontSize?: number;
}
function DataViewer({
data,
drawerKey = 'default',
prettyViewProps,
jsonString,
fontSize,
}: DataViewerProps): JSX.Element {
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Pretty);
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
const derivedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
const json = jsonString ?? derivedJson;
const handleViewModeChange = (value: string): void => {
const next = value as ViewMode;
@@ -55,7 +62,14 @@ function DataViewer({
};
return (
<div className="data-viewer">
<div
className="data-viewer"
style={
fontSize
? ({ '--data-viewer-font-size': `${fontSize}px` } as CSSProperties)
: undefined
}
>
<div className="data-viewer__toolbar">
<ToggleGroupSimple
type="single"
@@ -65,14 +79,14 @@ function DataViewer({
items={VIEW_MODE_OPTIONS}
testId="data-viewer-view-mode"
/>
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
<CopyButton value={json} ariaLabel="Copy JSON" />
</div>
<div className="data-viewer__content">
{viewMode === ViewMode.Pretty && (
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
)}
{viewMode === ViewMode.Json && <JsonView data={jsonString} />}
{viewMode === ViewMode.Json && <JsonView data={json} fontSize={fontSize} />}
</div>
</div>
);

View File

@@ -10,6 +10,7 @@ import './JsonView.styles.scss';
export interface JsonViewProps {
data: string;
height?: string;
fontSize?: number;
}
const editorOptions: EditorProps['options'] = {
@@ -56,7 +57,11 @@ function setEditorTheme(monaco: Monaco): void {
});
}
function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
function JsonView({
data,
height = '575px',
fontSize = 12,
}: JsonViewProps): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState(true);
const isDarkMode = useIsDarkMode();
@@ -65,7 +70,11 @@ function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
<MEditor
value={data}
language="json"
options={{ ...editorOptions, wordWrap: isWrapWord ? 'on' : 'off' }}
options={{
...editorOptions,
fontSize,
wordWrap: isWrapWord ? 'on' : 'off',
}}
onChange={(): void => {}}
height={height}
theme={isDarkMode ? 'signoz-dark' : 'light'}

View File

@@ -17,7 +17,7 @@
border-bottom: 1px solid var(--l2-border) !important;
border-radius: 0 !important;
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
font-size: 12px !important;
font-size: var(--data-viewer-font-size, 12px) !important;
line-height: 18px !important;
background: var(--l1-background) !important;
outline: none !important;
@@ -33,7 +33,7 @@
// Font spec: SF Mono, 12px, 400, 18px line-height, -0.5% letter-spacing
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
font-size: var(--data-viewer-font-size, 12px);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
@@ -77,6 +77,17 @@
margin-right: 16px;
}
// Pin the caret to the leaf padding-left (1.25em) so nested keys align with leaf
// keys at every depth; inline-flex centers the glyph on the text line.
&__arrow {
box-sizing: border-box !important;
width: 1.25em !important;
padding-right: 0 !important;
display: inline-flex !important;
align-items: center !important;
height: 18px; // match the row line-height so the caret centers on the text
}
// Leaf node row — full-width hover highlight
&__row {
display: flex !important;
@@ -84,6 +95,11 @@
position: relative;
isolation: isolate; // own stacking context so ::before sits behind content, not the panel bg
// react-json-tree indents leaf values with a depth-dependent padding-left
// (2.125em when nested vs 1.25em at top); we indent via `ul`, so pin it flat
// to keep leaf and nested keys aligned at every depth.
padding-left: 1.25em !important;
// Keep actions visible on hover, or while this row's menu is open
&:hover .pretty-view__actions,
&:has(> span [data-state='open']) .pretty-view__actions {
@@ -175,6 +191,13 @@
&__row &__value-row {
justify-content: space-between;
width: 100%;
// Anchor the ... to the top of the value (not centered in a tall value)
align-items: flex-start;
.pretty-view__actions {
position: sticky;
top: 4px;
}
}
// ... actions button — hidden by default, shown on row hover

View File

@@ -67,6 +67,15 @@ export interface PrettyViewProps {
*/
pinnedFieldsValue?: string[];
onPinnedFieldsChange?: (next: string[]) => void;
/**
* Optional per-leaf value renderer. Return a node to override the default
* `String(value)` rendering for that leaf, or `undefined` to fall back. Used
* e.g. to sanitize/format log body values (ANSI → color, unescape).
*/
renderLeafValue?: (
value: unknown,
keyPath: readonly (string | number)[],
) => React.ReactNode | undefined;
}
function PrettyView({
@@ -78,6 +87,7 @@ function PrettyView({
drawerKey = 'default',
pinnedFieldsValue,
onPinnedFieldsChange,
renderLeafValue,
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [, setCopy] = useCopyToClipboard();
@@ -233,7 +243,9 @@ function PrettyView({
>
<span
className="pretty-view__actions"
onClick={(e): void => e.stopPropagation()}
onClick={(e): void => {
e.stopPropagation();
}}
role="button"
tabIndex={0}
>
@@ -278,15 +290,16 @@ function PrettyView({
...keyPath: KeyPath
): React.ReactNode => {
const forwardPath = keyPathToForward(keyPath);
const custom = renderLeafValue?.(value, keyPath);
return renderWithActions({
content: String(valueAsString),
content: custom ?? String(valueAsString),
fieldKey: keyPathToDisplayString(keyPath),
fieldKeyPath: forwardPath,
value,
isNested: typeof value === 'object' && value !== null,
});
},
[renderWithActions],
[renderWithActions, renderLeafValue],
);
const pinnedLabelRenderer = useCallback(

View File

@@ -59,4 +59,12 @@ export const themeExtension = {
style: { ...style },
className: 'pretty-view__nested-row',
}),
arrowContainer: (
{ style }: { style: Record<string, unknown> },
arrowStyle: string,
): { style: Record<string, unknown>; className?: string } => ({
style: { ...style },
// Only pin the single caret's width (for key alignment).
className: arrowStyle === 'double' ? undefined : 'pretty-view__arrow',
}),
};

View File

@@ -29,6 +29,7 @@ type OmitAttributesResources = Pick<
ILog,
Exclude<
keyof ILog,
| 'date'
| 'resources_string'
| 'scope_string'
| 'attributesString'

View File

@@ -3,16 +3,17 @@ package flagger
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
var (
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
)
func MustNewRegistry() featuretypes.Registry {
@@ -97,6 +98,14 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureResolveSemconvFamilies,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether trace queries resolve a semantic-convention name to all the spellings of its family",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -40,7 +40,10 @@ func (c *conditionBuilder) ConditionFor(
return nil, nil, err
}
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
// Rule state history fields have no family support, so every logical field
// is single-member and flattens losslessly to its physical key.
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(ctx, orgID, nil, key, fieldKeys))
keys := querybuilder.SingleKeys(resolved)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
@@ -138,9 +141,9 @@ func (c *conditionBuilder) conditionForKey(
return "true", nil
}
if operator == qbtypes.FilterOperatorExists {
return fmt.Sprintf("has(JSONExtractKeys(labels), %s)", sb.Var(key.Name)), nil
return fmt.Sprintf("JSONHas(labels, %s)", sb.Var(key.Name)), nil
}
return fmt.Sprintf("not has(JSONExtractKeys(labels), %s)", sb.Var(key.Name)), nil
return fmt.Sprintf("not JSONHas(labels, %s)", sb.Var(key.Name)), nil
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator: %v", operator)

View File

@@ -6,6 +6,7 @@ import (
"strings"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -51,7 +52,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
return "", err
}
if col.Name == "labels" && key.Name != "labels" {
return fmt.Sprintf("JSONExtractString(labels, '%s')", strings.ReplaceAll(key.Name, "'", "\\'")), nil
return fmt.Sprintf("JSONExtractString(labels, %s)", querybuilder.ClickHouseStringLiteral(key.Name)), nil
}
return col.Name, nil
}
@@ -64,6 +65,24 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
return []*schema.Column{col}, nil
}
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
// A label inside the JSON gets a membership check, with the same condition
// that FieldFor uses for extraction; every real column always exists.
func (m *fieldMapper) ExistsFor(ctx context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
col, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
if col.Name == "labels" && key.Name != "labels" {
pred := fmt.Sprintf("JSONHas(labels, %s)", querybuilder.ClickHouseStringLiteral(key.Name))
if exists {
return pred, nil
}
return "not " + pred, nil
}
return "true", nil
}
func (m *fieldMapper) ColumnExpressionFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, field *telemetrytypes.TelemetryFieldKey, _ telemetrytypes.FieldDataType, _ map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
colName, err := m.FieldFor(ctx, orgID, tsStart, tsEnd, field)
if err != nil {

View File

@@ -274,6 +274,15 @@ func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string)
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete tokens")
}
// delete user_role assignments so the roles can be deleted later
_, err = tx.NewDelete().
Model(new(authtypes.UserRole)).
Where("user_id = ?", id).
Exec(ctx)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user roles")
}
// soft delete user
now := time.Now()
_, err = tx.NewUpdate().

View File

@@ -29,6 +29,7 @@ type builderQuery[T any] struct {
telemetryStore telemetrystore.TelemetryStore
orgID valuer.UUID
stmtBuilder qbtypes.StatementBuilder[T]
queryType qbtypes.QueryType
spec qbtypes.QueryBuilderQuery[T]
variables map[string]qbtypes.VariableItem
@@ -51,6 +52,7 @@ func newBuilderQuery[T any](
telemetryStore telemetrystore.TelemetryStore,
orgID valuer.UUID,
stmtBuilder qbtypes.StatementBuilder[T],
queryType qbtypes.QueryType,
spec qbtypes.QueryBuilderQuery[T],
tr qbtypes.TimeRange,
kind qbtypes.RequestType,
@@ -62,6 +64,7 @@ func newBuilderQuery[T any](
telemetryStore: telemetryStore,
orgID: orgID,
stmtBuilder: stmtBuilder,
queryType: queryType,
spec: spec,
variables: variables,
fromMS: tr.From,
@@ -79,9 +82,15 @@ func (q *builderQuery[T]) Fingerprint() string {
return ""
}
// AI trace aggregations qualify and rank traces on whole-window per-trace
// values, which do not decompose into cacheable time buckets.
if q.queryType == qbtypes.QueryTypeBuilderAI {
return ""
}
// Create a deterministic fingerprint for builder queries
// This needs to include all fields that affect the query results
parts := []string{"builder"}
parts := []string{q.queryType.StringValue()}
// Add signal type
parts = append(parts, fmt.Sprintf("signal=%s", q.spec.Signal.StringValue()))

View File

@@ -3,6 +3,7 @@ package querier
import (
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -20,7 +21,8 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "fingerprint includes shiftby when ShiftBy field is set",
query: &builderQuery[qbtypes.MetricAggregation]{
kind: qbtypes.RequestTypeTimeSeries,
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 3600,
@@ -40,7 +42,8 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "fingerprint includes shiftby but not other functions",
query: &builderQuery[qbtypes.MetricAggregation]{
kind: qbtypes.RequestTypeTimeSeries,
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 3600,
@@ -63,7 +66,8 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "no shiftby in fingerprint when ShiftBy is zero",
query: &builderQuery[qbtypes.MetricAggregation]{
kind: qbtypes.RequestTypeTimeSeries,
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 0,
@@ -94,6 +98,28 @@ func TestBuilderQueryFingerprint(t *testing.T) {
}
}
func TestBuilderQueryFingerprintQueryType(t *testing.T) {
spec := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model EXISTS"},
}
regular := &builderQuery[qbtypes.TraceAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
spec: spec,
}
ai := &builderQuery[qbtypes.TraceAggregation]{
queryType: qbtypes.QueryTypeBuilderAI,
kind: qbtypes.RequestTypeTimeSeries,
spec: spec,
}
assert.True(t, strings.HasPrefix(regular.Fingerprint(), qbtypes.QueryTypeBuilder.StringValue()+"&"))
assert.Empty(t, ai.Fingerprint())
}
func TestMakeBucketsOrder(t *testing.T) {
// Test that makeBuckets returns buckets in reverse chronological order by default
// Using milliseconds as input - need > 1 hour range to get multiple buckets

View File

@@ -305,7 +305,7 @@ func (q *querier) buildQueries(
}
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryTypeBuilder:
@@ -313,7 +313,7 @@ func (q *querier) buildQueries(
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
@@ -323,7 +323,7 @@ func (q *querier) buildQueries(
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -340,9 +340,9 @@ func (q *querier) buildQueries(
if spec.Source == telemetrytypes.SourceMeter {
event.Source = telemetrytypes.SourceMeter.StringValue()
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
} else {
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
}
queries[spec.Name] = bq
@@ -618,7 +618,7 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
if spec.Source == telemetrytypes.SourceAudit {
liveTailStmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, liveTailStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, liveTailStmtBuilder, query.Type, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
"id": {
Value: updatedLogID,
},
@@ -941,8 +941,9 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
// reuse the original query's statement builder so an AI query keeps its AI builder
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
// reuse the original query's statement builder and type so an AI query
// keeps its AI builder and cache key
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()
@@ -952,16 +953,16 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
if qt.spec.Source == telemetrytypes.SourceAudit {
shiftStmtBuilder = q.auditStmtBuilder
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
case *builderQuery[qbtypes.MetricAggregation]:
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
if qt.spec.Source == telemetrytypes.SourceMeter {
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *traceOperatorQuery:
specCopy := qt.spec.Copy()
return &traceOperatorQuery{

View File

@@ -0,0 +1,17 @@
package querybuilder
import "strings"
// ClickHouseStringLiteral quotes a value for a ClickHouse string literal.
func ClickHouseStringLiteral(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, `'`, `\'`)
return "'" + escaped + "'"
}
// ClickHouseIdentifier quotes a value for a ClickHouse identifier.
func ClickHouseIdentifier(value string) string {
escaped := strings.ReplaceAll(value, `\`, `\\`)
escaped = strings.ReplaceAll(escaped, "`", "\\`")
return "`" + escaped + "`"
}

View File

@@ -0,0 +1,17 @@
package querybuilder
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestClickHouseQuoting(t *testing.T) {
t.Run("string literal", func(t *testing.T) {
assert.Equal(t, `'name\'\\); SELECT 1 --'`, ClickHouseStringLiteral(`name'\); SELECT 1 --`))
})
t.Run("identifier", func(t *testing.T) {
assert.Equal(t, "`name\\`\\\\); SELECT 1 --`", ClickHouseIdentifier("name`\\); SELECT 1 --"))
})
}

View File

@@ -0,0 +1,64 @@
package querybuilder
import (
"context"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// semconvFamiliesEnabled evaluates the resolve_semconv_families flag for the
// org. A nil flagger means off, so a caller without family support stays
// literal by default.
func semconvFamiliesEnabled(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger) bool {
if fl == nil {
return false
}
return fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID))
}
// ExpandKeySelectorsForFamilies adds selectors for the other members of each
// semantic-convention family that a selector names. The metadata fetched for
// a query then contains each spelling that MatchingLogicalFields can group.
// This function is the prefetch of the resolution layer: statement builders
// call it after they derive the selectors, and the metadata store stays
// family-blind (autocomplete responses keep the literal spelling that the
// user typed). It does nothing when the resolve_semconv_families flag is off
// for the org. Only trace selectors expand today, because that matches the
// family support. Fuzzy (search-style) selectors never expand.
func ExpandKeySelectorsForFamilies(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, selectors []*telemetrytypes.FieldKeySelector) []*telemetrytypes.FieldKeySelector {
if !semconvFamiliesEnabled(ctx, orgID, fl) {
return selectors
}
out := selectors
seen := make(map[string]bool, len(selectors))
for _, selector := range selectors {
seen[selector.Name] = true
}
for _, selector := range selectors {
if selector.Signal != telemetrytypes.SignalTraces ||
selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
continue
}
members := semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: selector.Name,
Signal: selector.Signal,
FieldContext: selector.FieldContext,
})
for _, member := range members {
if seen[member] {
continue
}
seen[member] = true
expanded := *selector
expanded.Name = member
out = append(out, &expanded)
}
}
return out
}

View File

@@ -21,24 +21,25 @@ const (
hasTokenFunctionDocURL = "https://signoz.io/docs/userguide/functions-reference/#hastoken-function"
)
// ResolveKeys picks which matching field keys a filter term builds conditions for.
// With 0 or 1 match it returns the input unchanged and no warning. When a name is
// ambiguous it returns a warning; a resource+attribute mix defaults to the resource
// keys (the common intent), noted in the warning.
func ResolveKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeysForName []*telemetrytypes.TelemetryFieldKey) ([]*telemetrytypes.TelemetryFieldKey, string) {
if len(fieldKeysForName) <= 1 {
return fieldKeysForName, ""
// ResolveLogicalFields picks which logical fields a filter term builds conditions
// for. With 0 or 1 field it returns the input unchanged and no warning. When a
// name is ambiguous (several logical fields — a family is one field and never
// ambiguous with itself) it returns a warning; a resource+attribute mix defaults
// to the resource fields (the common intent), noted in the warning.
func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields []*telemetrytypes.LogicalField) ([]*telemetrytypes.LogicalField, string) {
if len(logicalFields) <= 1 {
return logicalFields, ""
}
warning := fmt.Sprintf(
"Key `%s` is ambiguous, found %d different combinations of field context / data type: %v.",
field.Name,
len(fieldKeysForName),
fieldKeysForName,
len(logicalFields),
logicalFields,
)
hasResource, hasAttribute := false, false
for _, item := range fieldKeysForName {
for _, item := range logicalFields {
switch item.FieldContext {
case telemetrytypes.FieldContextResource:
hasResource = true
@@ -49,18 +50,40 @@ func ResolveKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeysForName []*te
// when there is both resource and attribute context, default to resource only
if hasResource && hasAttribute {
filteredKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(fieldKeysForName))
for _, item := range fieldKeysForName {
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
for _, item := range logicalFields {
if item.FieldContext == telemetrytypes.FieldContextResource {
filteredKeys = append(filteredKeys, item)
filtered = append(filtered, item)
}
}
fieldKeysForName = filteredKeys
logicalFields = filtered
warning += " " + "Using `resource` context by default. To query attributes explicitly, " +
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", field.Name)
}
return fieldKeysForName, warning
return logicalFields, warning
}
// WrapAsLogicalFields wraps physical keys (candidate or synthesized) as
// single-member logical fields addressed by the requested spelling.
func WrapAsLogicalFields(requestedName string, keys []*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
fields := make([]*telemetrytypes.LogicalField, 0, len(keys))
for _, key := range keys {
fields = append(fields, telemetrytypes.SingleLogicalField(requestedName, key))
}
return fields
}
// SingleKeys flattens logical fields to their single members. It is the
// adapter for signals whose fields are single-member by construction (every
// signal without family support); their condition builders keep compiling per
// physical key.
func SingleKeys(fields []*telemetrytypes.LogicalField) []*telemetrytypes.TelemetryFieldKey {
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(fields))
for _, field := range fields {
keys = append(keys, field.Single())
}
return keys
}
// NewKeyNotFoundError builds the error a condition builder returns when a filter term

View File

@@ -0,0 +1,96 @@
package querybuilder
import (
"context"
"fmt"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// The two functions below are the only place family expressions are built.
// They compose exclusively from the mapper's per-key primitives (FieldFor,
// ExistsFor), so every member honors its own storage: materialized columns,
// evolutions, and JSON plans ride the member keys, and a signal supports
// families the moment its primitives are correct.
// LogicalValueExpr returns the value expression for a resolved logical field:
// the member's own expression for a single-member field, and a current-first
// merge across the members' expressions for a family.
func LogicalValueExpr(
ctx context.Context,
orgID valuer.UUID,
tsStart, tsEnd uint64,
fm qbtypes.FieldMapper,
logical *telemetrytypes.LogicalField,
) (string, error) {
if !logical.IsFamily() {
return fm.FieldFor(ctx, orgID, tsStart, tsEnd, logical.Single())
}
memberExprs := make([]string, 0, len(logical.Members))
for _, member := range logical.Members {
expr, err := fm.FieldFor(ctx, orgID, tsStart, tsEnd, member)
if err != nil {
return "", err
}
memberExprs = append(memberExprs, expr)
}
if logical.FieldDataType == telemetrytypes.FieldDataTypeString {
// The trailing '' keeps single-key semantics for rows without any
// member: string maps read '' for an absent key, and negative
// operators must keep including such rows (see AddDefaultExistsFilter).
// A NULL tail would drop them: NULL != 'x' evaluates to NULL, and the
// row falls out of the result.
values := make([]string, 0, len(memberExprs))
for _, expr := range memberExprs {
values = append(values, fmt.Sprintf("NULLIF(%s, '')", expr))
}
return "COALESCE(" + strings.Join(values, ", ") + ", '')", nil
}
// Numeric and boolean maps return zero for an absent key. If a family of
// either type is enabled, this tail must become zero too.
branches := make([]string, 0, len(logical.Members)*2)
for i, member := range logical.Members {
guard, err := fm.ExistsFor(ctx, orgID, tsStart, tsEnd, member, true)
if err != nil {
return "", err
}
branches = append(branches, guard, memberExprs[i])
}
return "multiIf(" + strings.Join(branches, ", ") + ", NULL)", nil
}
// LogicalExistsExpr returns the existence predicate for a resolved logical
// field: the member's own predicate for a single-member field, presence of
// any member for a family.
func LogicalExistsExpr(
ctx context.Context,
orgID valuer.UUID,
tsStart, tsEnd uint64,
fm qbtypes.FieldMapper,
logical *telemetrytypes.LogicalField,
exists bool,
) (string, error) {
if !logical.IsFamily() {
return fm.ExistsFor(ctx, orgID, tsStart, tsEnd, logical.Single(), exists)
}
guards := make([]string, 0, len(logical.Members))
for _, member := range logical.Members {
guard, err := fm.ExistsFor(ctx, orgID, tsStart, tsEnd, member, true)
if err != nil {
return "", err
}
guards = append(guards, guard)
}
combined := "(" + strings.Join(guards, " OR ") + ")"
if exists {
return combined, nil
}
return "NOT " + combined, nil
}

View File

@@ -0,0 +1,95 @@
package querybuilder
import (
"context"
"testing"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubFieldMapper provides just the two per-key primitives the shared
// composition builds on; the remaining FieldMapper methods are unused here.
type stubFieldMapper struct{}
func (stubFieldMapper) FieldFor(_ context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
return "value(" + key.Name + ")", nil
}
func (stubFieldMapper) ExistsFor(_ context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
if exists {
return "has(" + key.Name + ")", nil
}
return "NOT has(" + key.Name + ")", nil
}
func (stubFieldMapper) ColumnFor(context.Context, valuer.UUID, uint64, uint64, *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
return nil, qbtypes.ErrColumnNotFound
}
func (stubFieldMapper) ColumnExpressionFor(context.Context, valuer.UUID, uint64, uint64, *telemetrytypes.TelemetryFieldKey, telemetrytypes.FieldDataType, map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
return "", qbtypes.ErrColumnNotFound
}
func (stubFieldMapper) CandidateKeys(context.Context, valuer.UUID, *telemetrytypes.TelemetryFieldKey, any, map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
return nil
}
func stringFamily(names ...string) *telemetrytypes.LogicalField {
members := make([]*telemetrytypes.TelemetryFieldKey, 0, len(names))
for _, name := range names {
members = append(members, &telemetrytypes.TelemetryFieldKey{Name: name, FieldDataType: telemetrytypes.FieldDataTypeString})
}
return &telemetrytypes.LogicalField{Name: names[0], FieldDataType: telemetrytypes.FieldDataTypeString, Members: members}
}
func TestLogicalValueExprSingleMemberDelegatesToFieldFor(t *testing.T) {
logical := telemetrytypes.SingleLogicalField("a", &telemetrytypes.TelemetryFieldKey{Name: "a"})
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical)
require.NoError(t, err)
assert.Equal(t, "value(a)", expr)
}
func TestLogicalValueExprStringFamilyMergesCurrentFirst(t *testing.T) {
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, stringFamily("current", "old"))
require.NoError(t, err)
// The trailing '' preserves keyless-row semantics for negative operators.
assert.Equal(t, "COALESCE(NULLIF(value(current), ''), NULLIF(value(old), ''), '')", expr)
}
func TestLogicalValueExprNumericFamilyGuardsEveryMember(t *testing.T) {
logical := &telemetrytypes.LogicalField{
Name: "current",
FieldDataType: telemetrytypes.FieldDataTypeNumber,
Members: []*telemetrytypes.TelemetryFieldKey{
{Name: "current", FieldDataType: telemetrytypes.FieldDataTypeNumber},
{Name: "old", FieldDataType: telemetrytypes.FieldDataTypeNumber},
},
}
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical)
require.NoError(t, err)
assert.Equal(t, "multiIf(has(current), value(current), has(old), value(old), NULL)", expr)
}
func TestLogicalExistsExprSingleMemberDelegatesToExistsFor(t *testing.T) {
logical := telemetrytypes.SingleLogicalField("a", &telemetrytypes.TelemetryFieldKey{Name: "a"})
expr, err := LogicalExistsExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical, false)
require.NoError(t, err)
assert.Equal(t, "NOT has(a)", expr)
}
func TestLogicalExistsExprFamilyIsAnyMemberPresence(t *testing.T) {
family := stringFamily("current", "old")
expr, err := LogicalExistsExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, family, true)
require.NoError(t, err)
assert.Equal(t, "(has(current) OR has(old))", expr)
expr, err = LogicalExistsExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, family, false)
require.NoError(t, err)
assert.Equal(t, "NOT (has(current) OR has(old))", expr)
}

View File

@@ -0,0 +1,239 @@
package querybuilder
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// familiesOn returns a flagger with resolve_semconv_families on.
func familiesOn(t *testing.T) flagger.Flagger {
return flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
}
func memberNames(logical *telemetrytypes.LogicalField) []string {
names := make([]string, 0, len(logical.Members))
for _, member := range logical.Members {
names = append(names, member.Name)
}
return names
}
// The deployment.environment(.name) family (enabled in pkg/semconv) drives the
// grouping tests below.
// With the resolve_semconv_families flag off, matches stay single-member and
// selectors stay literal, even when the metadata map has both spellings.
func TestFamiliesOffByDefault(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, flaggertest.New(t), &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
require.Len(t, fields, 1)
assert.False(t, fields[0].IsFamily())
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
selectors := []*telemetrytypes.FieldKeySelector{
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
}
assert.Len(t, ExpandKeySelectorsForFamilies(context.Background(), valuer.UUID{}, flaggertest.New(t), selectors), 1)
}
func TestMatchingLogicalFieldsGroupsFamilyMembers(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
for _, requested := range []string{"deployment.environment.name", "deployment.environment"} {
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{Name: requested}, fieldKeys)
require.Len(t, fields, 1, "a family is one logical field, requested via %s", requested)
logical := fields[0]
assert.Equal(t, requested, logical.Name, "response identity is the requested spelling")
assert.Equal(t, telemetrytypes.FieldContextResource, logical.FieldContext)
assert.True(t, logical.IsFamily())
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(logical),
"members are current-first regardless of the requested spelling")
}
}
// Member precedence is the family's current-first order, not lookup arrival
// order: a current-name key found only under its context-prefixed spelling
// arrives in the second lookup pass yet must still sort first.
func TestMatchingLogicalFieldsOrdersMembersByFamilyRank(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"resource.deployment.environment.name": {{
Name: "resource.deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
FieldContext: telemetrytypes.FieldContextResource,
}, fieldKeys)
require.Len(t, fields, 1)
assert.Equal(t, []string{"resource.deployment.environment.name", "deployment.environment"}, memberNames(fields[0]))
}
// Non-trace signals have no family support: the requested spelling stays
// literal, and a family member name never pulls in its siblings.
func TestMatchingLogicalFieldsKeepsLogsLiteral(t *testing.T) {
logsKey := func(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
}
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {logsKey("deployment.environment.name")},
"deployment.environment": {logsKey("deployment.environment")},
}
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
require.Len(t, fields, 1)
assert.False(t, fields[0].IsFamily())
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
}
// A family and a genuine same-name collision stack cleanly: the family stays
// one logical field, the collision adds another, and resource preference keeps
// the family as a unit.
func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {
{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), requested, fieldKeys)
require.Len(t, fields, 2, "resource family + attribute collision")
resolved, warning := ResolveLogicalFields(requested, fields)
assert.NotEmpty(t, warning)
require.Len(t, resolved, 1)
assert.Equal(t, telemetrytypes.FieldContextResource, resolved[0].FieldContext)
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(resolved[0]))
}
// Members of a family with different data types never merge: the identity
// (signal, context, data type) separates them into distinct logical fields.
func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}},
}
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
require.Len(t, fields, 2)
for _, logical := range fields {
assert.False(t, logical.IsFamily())
}
}
func TestExpandKeySelectorsForFamilies(t *testing.T) {
selectors := []*telemetrytypes.FieldKeySelector{
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
{Name: "service.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalLogs, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
}
expanded := ExpandKeySelectorsForFamilies(context.Background(), valuer.UUID{}, familiesOn(t), selectors)
names := make([]string, 0, len(expanded))
for _, selector := range expanded {
names = append(names, selector.Name)
}
assert.Equal(t, []string{
"deployment.environment.name",
"service.name",
"deployment.environment.name",
"deployment.environment",
}, names, "one sibling selector for the trace family member; logs and non-family names untouched")
sibling := expanded[len(expanded)-1]
assert.Equal(t, telemetrytypes.SignalTraces, sibling.Signal)
assert.Equal(t, telemetrytypes.FieldSelectorMatchTypeExact, sibling.SelectorMatchType)
}
func TestExpandKeySelectorsForFamiliesDeduplicatesAndSkipsFuzzy(t *testing.T) {
both := []*telemetrytypes.FieldKeySelector{
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
{Name: "deployment.environment", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact},
}
assert.Len(t, ExpandKeySelectorsForFamilies(context.Background(), valuer.UUID{}, familiesOn(t), both), 2, "both spellings already referenced")
fuzzy := []*telemetrytypes.FieldKeySelector{
{Name: "deployment.environment.name", Signal: telemetrytypes.SignalTraces, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeFuzzy},
}
assert.Len(t, ExpandKeySelectorsForFamilies(context.Background(), valuer.UUID{}, familiesOn(t), fuzzy), 1, "fuzzy (search-style) selectors never expand")
}

View File

@@ -9,7 +9,9 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/semconv"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -27,6 +29,7 @@ const stringMatchingOperatorDocURL = "https://signoz.io/docs/userguide/operators
type filterExpressionVisitor struct {
context context.Context
orgID valuer.UUID
fl flagger.Flagger
fieldMapper qbtypes.FieldMapper
conditionBuilder qbtypes.ConditionBuilder
warnings []string
@@ -48,8 +51,11 @@ type filterExpressionVisitor struct {
}
type FilterExprVisitorOpts struct {
Context context.Context
OrgID valuer.UUID
Context context.Context
OrgID valuer.UUID
// Flagger evaluates the resolve_semconv_families flag during resolution.
// A nil Flagger keeps resolution literal.
Flagger flagger.Flagger
Logger *slog.Logger
FieldMapper qbtypes.FieldMapper
ConditionBuilder qbtypes.ConditionBuilder
@@ -68,6 +74,7 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
return &filterExpressionVisitor{
context: opts.Context,
orgID: opts.OrgID,
fl: opts.Flagger,
fieldMapper: opts.FieldMapper,
conditionBuilder: opts.ConditionBuilder,
fieldKeys: opts.FieldKeys,
@@ -360,7 +367,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
return ErrorConditionLiteral
}
}
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.TelemetryFieldKey{v.fullTextColumn}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText))
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(v.fullTextColumn.Name, v.fullTextColumn)}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(searchText))
if !ok {
return ErrorConditionLiteral
}
@@ -379,7 +386,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
// VisitComparison handles all comparison operators.
func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key := v.Visit(ctx.Key()).(*telemetrytypes.TelemetryFieldKey)
matching := MatchingFieldKeys(key, v.fieldKeys)
matching := MatchingLogicalFields(v.context, v.orgID, v.fl, key, v.fieldKeys)
// Handle EXISTS specially
if ctx.EXISTS() != nil {
@@ -675,7 +682,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
v.errors = append(v.errors, "full text search is not supported")
return ErrorConditionLiteral
}
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.TelemetryFieldKey{v.fullTextColumn}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text))
conds, ok := v.buildConditions(v.fullTextColumn, []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(v.fullTextColumn.Name, v.fullTextColumn)}, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text))
if !ok {
return ErrorConditionLiteral
}
@@ -730,7 +737,7 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
return ErrorConditionLiteral
}
conds, ok := v.buildConditions(key, MatchingFieldKeys(key, v.fieldKeys), operator, value)
conds, ok := v.buildConditions(key, MatchingLogicalFields(v.context, v.orgID, v.fl, key, v.fieldKeys), operator, value)
if !ok {
return ErrorConditionLiteral
}
@@ -922,7 +929,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
// buildConditions invokes the condition builder for a filter term, folding its
// warnings/errors into visitor state; returns false if an error was recorded.
func (v *filterExpressionVisitor) buildConditions(key *telemetrytypes.TelemetryFieldKey, matching []*telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) ([]string, bool) {
func (v *filterExpressionVisitor) buildConditions(key *telemetrytypes.TelemetryFieldKey, matching []*telemetrytypes.LogicalField, op qbtypes.FilterOperator, value any) ([]string, bool) {
conds, warns, err := v.conditionBuilder.ConditionFor(v.context, v.orgID, v.startNs, v.endNs, key, v.fieldKeys, qbtypes.ConditionBuilderOptions{SkipResourceFilter: v.skipResourceFilter}, op, value, v.builder)
if err != nil {
_, _, _, _, errURL, _ := errors.Unwrapb(err)
@@ -979,30 +986,161 @@ func assignIfEmpty(s *string, value string) {
}
}
// MatchingFieldKeys returns the field keys from the map that match the given key,
// honoring any context/data type the user specified.
func MatchingFieldKeys(field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
fieldKeysForName := []*telemetrytypes.TelemetryFieldKey{}
// match by name; keep items whose context and data type match (unspecified matches any)
for _, item := range fieldKeys[field.Name] {
if (field.FieldContext == telemetrytypes.FieldContextUnspecified || field.FieldContext == item.FieldContext) &&
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || field.FieldDataType == item.FieldDataType) {
fieldKeysForName = append(fieldKeysForName, item)
}
// familyMemberNames returns the physical spellings to look up for the
// referenced key: the semantic-convention family members (current-first) when
// the resolve_semconv_families flag is on for the org and the key can resolve
// to traces, else just the requested name. Only trace field mappers understand
// families today; logs and metrics keep the requested spelling until theirs
// land.
func familyMemberNames(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, field *telemetrytypes.TelemetryFieldKey) []string {
if !semconvFamiliesEnabled(ctx, orgID, fl) {
return []string{field.Name}
}
// A context may have been split off a name that legitimately contained it (e.g.
// `attribute.key`); also look up the context-prefixed name so both readings resolve.
if field.FieldContext != telemetrytypes.FieldContextUnspecified {
contextPrefixedFieldName := fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), field.Name)
for _, item := range fieldKeys[contextPrefixedFieldName] {
// Context already matched via the lookup key; only data type needs checking.
if field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || item.FieldDataType == field.FieldDataType {
fieldKeysForName = append(fieldKeysForName, item)
}
}
if field.Signal != telemetrytypes.SignalUnspecified && field.Signal != telemetrytypes.SignalTraces {
return []string{field.Name}
}
return fieldKeysForName
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: field.FieldContext,
})
}
// MatchingLogicalFields resolves the referenced key against the metadata map
// into logical fields, honoring any context/data type the user specified.
//
// Physical keys that are members of one semantic-convention family (traces
// only today) group into one logical field per (signal, context, data type)
// identity, members ordered current-first. Every other matching key becomes
// its own single-member logical field. Ambiguity is the length of the
// returned slice: one family is one element and is never ambiguous with
// itself, but the slice can hold several logical fields — including several
// family fields, one per identity, when the family exists under more than
// one context or data type. Members alias the metadata map entries; nothing
// is copied or mutated.
//
// Family grouping only happens when the resolve_semconv_families flag is on
// for the org. A nil flagger means off: every match then stays a
// single-member logical field.
func MatchingLogicalFields(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
members := familyMemberNames(ctx, orgID, fl, field)
matches := collectMemberMatches(field, members, fieldKeys)
return groupIntoLogicalFields(field.Name, len(members) > 1, matches)
}
// memberMatch pairs a metadata entry with the family rank of the member name
// it matched under. The stored name of a context-prefixed match differs from
// the member name, so the rank must travel with the match.
type memberMatch struct {
key *telemetrytypes.TelemetryFieldKey
rank int
}
// matchesRequestedIdentity reports whether the entry fits the context and data
// type that the request specified; unspecified matches any. A context-prefixed
// lookup already matched the context through the lookup key itself.
func matchesRequestedIdentity(field, item *telemetrytypes.TelemetryFieldKey, contextMatched bool) bool {
if !contextMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
return false
}
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
return false
}
return true
}
// inFamilyScope reports whether a match found under a sibling member name is
// legitimate: the entry must be trace metadata, and the member must be in the
// family of the requested name for the entry's context. A member lookup can
// otherwise find a same-named field in a scope where the family does not
// apply.
func inFamilyScope(field, item *telemetrytypes.TelemetryFieldKey, memberName string) bool {
if item.Signal != telemetrytypes.SignalTraces {
return false
}
return slices.Contains(semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: item.FieldContext,
}), memberName)
}
// collectMemberMatches finds the metadata entries for every member spelling:
// first under the member names, then under their context-prefixed spellings
// (a context can be a legitimate part of a stored name, e.g. `attribute.key`).
func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []string, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []memberMatch {
matches := make([]memberMatch, 0)
collect := func(lookupName string, rank int, memberName string, contextMatched bool) {
for _, item := range fieldKeys[lookupName] {
if !matchesRequestedIdentity(field, item, contextMatched) {
continue
}
if memberName != field.Name && !inFamilyScope(field, item, memberName) {
continue
}
matches = append(matches, memberMatch{key: item, rank: rank})
}
}
for rank, member := range members {
collect(member, rank, member, false)
}
if field.FieldContext != telemetrytypes.FieldContextUnspecified {
for rank, member := range members {
collect(fmt.Sprintf("%s.%s", field.FieldContext.StringValue(), member), rank, member, true)
}
}
return matches
}
// groupIntoLogicalFields turns matches into logical fields. Trace entries in
// family mode group by their (signal, context, data type) identity; every
// other entry becomes its own single-member field. Members sort by family
// rank at the end: precedence is a property of the family, not of the order
// in which the lookups found the members.
func groupIntoLogicalFields(requestedName string, familyMode bool, matches []memberMatch) []*telemetrytypes.LogicalField {
fields := make([]*telemetrytypes.LogicalField, 0, len(matches))
groups := make(map[string]*telemetrytypes.LogicalField)
ranks := make(map[*telemetrytypes.TelemetryFieldKey]int)
for _, match := range matches {
if !familyMode || match.key.Signal != telemetrytypes.SignalTraces {
fields = append(fields, telemetrytypes.SingleLogicalField(requestedName, match.key))
continue
}
identity := match.key.Signal.StringValue() + ";" + match.key.FieldContext.StringValue() + ";" + match.key.FieldDataType.StringValue()
group, ok := groups[identity]
if !ok {
group = &telemetrytypes.LogicalField{
Name: requestedName,
Signal: match.key.Signal,
FieldContext: match.key.FieldContext,
FieldDataType: match.key.FieldDataType,
}
groups[identity] = group
fields = append(fields, group)
}
if groupHasMemberNamed(group, match.key.Name) {
continue
}
ranks[match.key] = match.rank
group.Members = append(group.Members, match.key)
}
for _, logical := range fields {
slices.SortStableFunc(logical.Members, func(a, b *telemetrytypes.TelemetryFieldKey) int {
return ranks[a] - ranks[b]
})
}
return fields
}
func groupHasMemberNamed(group *telemetrytypes.LogicalField, name string) bool {
for _, member := range group.Members {
if member.Name == name {
return true
}
}
return false
}

View File

@@ -588,9 +588,11 @@ func TestVisitKey(t *testing.T) {
// VisitKey only parses; the condition builder matches, resolves ambiguity
// and decides not-found handling. Replay that here against the generic
// builder behavior (error unless the key is ignored).
matching := MatchingFieldKeys(key, tt.fieldKeys)
keys, warning := ResolveKeys(key, matching)
// builder behavior (error unless the key is ignored). The test maps carry
// no signal, so every logical field is single-member and flattens losslessly.
matching := MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, key, tt.fieldKeys)
resolved, warning := ResolveLogicalFields(key, matching)
keys := SingleKeys(resolved)
var gotErrors []string
var gotMainErrURL, gotMainWrnURL string
@@ -766,7 +768,8 @@ func (b *resourceConditionBuilder) ConditionFor(
return nil, nil, nil
}
keys, warning := ResolveKeys(key, MatchingFieldKeys(key, fieldKeys))
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, key, fieldKeys))
keys := SingleKeys(resolved)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
@@ -808,7 +811,8 @@ func (b *conditionBuilder) ConditionFor(
return []string{fmt.Sprintf("%s_cond", key.Name)}, nil, nil
}
keys, warning := ResolveKeys(key, MatchingFieldKeys(key, fieldKeys))
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, key, fieldKeys))
keys := SingleKeys(resolved)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)

View File

@@ -241,6 +241,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
sqlmigration.NewRestructureAuthDomainConfigFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
sqlmigration.NewDeleteOrphanUserRolesFactory(),
)
}

View File

@@ -0,0 +1,65 @@
package sqlmigration
import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type deleteOrphanUserRoles struct{}
func NewDeleteOrphanUserRolesFactory() factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("delete_orphan_user_roles"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &deleteOrphanUserRoles{}, nil
},
)
}
func (migration *deleteOrphanUserRoles) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *deleteOrphanUserRoles) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
var deletedUserIDs []string
err = tx.NewSelect().
Model(new(types.User)).
Column("id").
Where("status = ?", types.UserStatusDeleted).
Scan(ctx, &deletedUserIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
if len(deletedUserIDs) == 0 {
return tx.Commit()
}
_, err = tx.NewDelete().
Model(new(authtypes.UserRole)).
Where("user_id IN (?)", bun.In(deletedUserIDs)).
Exec(ctx)
if err != nil {
return err
}
return tx.Commit()
}
func (migration *deleteOrphanUserRoles) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -994,8 +994,8 @@ func TestBuild_TraceList_MultiVariantGateKey(t *testing.T) {
assert.Contains(t, got, "mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_number, 'gen_ai.tool.name')")
}
// `trace.` marks a trace-level aggregate; `tracefield.` routes trace-level too but is
// not a rewritable alias, so the HAVING rewriter rejects it.
// A `trace.`-prefixed aggregate in the filter box and the same condition in the
// explicit Having box build the same query; output-only aggregates are rejected.
func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
b := newTestBuilder(t)
build := func(q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (*qbtypes.Statement, error) {
@@ -1003,19 +1003,14 @@ func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
return b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, q, nil)
}
_, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
viaTrace, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"}})
require.NoError(t, err)
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Filter: &qbtypes.Filter{Expression: "tracefield.output_tokens > 1000"}})
require.Error(t, err)
assert.Contains(t, err.Error(), "Invalid references in `Having` expression: [tracefield.output_tokens]")
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Having: &qbtypes.Having{Expression: "tracefield.output_tokens > 1000"}})
require.Error(t, err)
assert.Contains(t, err.Error(), "Invalid references in `Having` expression: [tracefield.output_tokens]")
viaHaving, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Having: &qbtypes.Having{Expression: "trace.output_tokens > 1000"}})
require.NoError(t, err)
assert.Equal(t, viaTrace.Query, viaHaving.Query)
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Filter: &qbtypes.Filter{Expression: "trace.span_count > 3"}})
@@ -1023,7 +1018,8 @@ func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
assert.Contains(t, err.Error(), "cannot be used")
}
// Query variables in a trace-level condition are substituted into the HAVING.
// Query variables in a trace-level condition resolve like span filters: bound args,
// list/IN handling, dynamic __all__ dropping the condition.
func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
b := newTestBuilder(t)
build := func(expr string, vars map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
@@ -1035,17 +1031,18 @@ func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
}, vars)
}
// scalar variable -> literal in HAVING
// scalar variable -> bound arg via the filter pipeline
stmt, err := build("trace.output_tokens > $threshold",
map[string]qbtypes.VariableItem{"threshold": {Value: 700}})
require.NoError(t, err)
assert.Contains(t, stmt.Query, "HAVING output_tokens > 700")
assert.Contains(t, stmt.Query, "HAVING output_tokens > ?")
assert.Contains(t, stmt.Args, float64(700))
// list variable with IN
stmt, err = build("trace.llm_call_count IN $counts",
map[string]qbtypes.VariableItem{"counts": {Value: []any{1, 2}}})
require.NoError(t, err)
assert.Contains(t, stmt.Query, "HAVING llm_call_count IN")
assert.Contains(t, stmt.Query, "HAVING llm_call_count IN (?, ?)")
// dynamic __all__ -> condition dropped, no HAVING at all
stmt, err = build("trace.output_tokens > $threshold",
@@ -1053,7 +1050,7 @@ func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
require.NoError(t, err)
assert.NotContains(t, stmt.Query, "HAVING")
// unresolved variable -> rejected, not compared as a literal
// unresolved variable -> rejected, though only as an unknown aggregate today
_, err = build("trace.output_tokens > $missing", map[string]qbtypes.VariableItem{"other": {Value: 1}})
require.Error(t, err)
}

View File

@@ -0,0 +1,753 @@
package aistatementbuilder
import (
"context"
"testing"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The builder assumes at least one aggregation; request validation is what enforces it.
func TestBuild_Aggregation_NoAggregations_RejectedByRequestValidation(t *testing.T) {
for _, rt := range []qbtypes.RequestType{qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries} {
req := qbtypes.QueryRangeRequest{
Start: testStartMs,
End: testEndMs,
RequestType: rt,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
},
}},
},
}
require.ErrorContains(t, req.Validate(), "at least one aggregation is required", rt.StringValue())
}
}
// Traces without token spans yield NULL, which the outer avg skips.
func TestBuild_FullSQL_Scalar_TraceAgg(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __scoped_traces AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
)
SELECT avg(output_tokens) AS __result_0
FROM __scoped_traces
ORDER BY __result_0 DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// A span-level filter is ANDed into the per-trace scan's WHERE, next to the gate mask.
func TestBuild_FullSQL_Scalar_SpanFilter(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini'"},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __scoped_traces AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
GROUP BY trace_id
)
SELECT avg(output_tokens) AS __result_0
FROM __scoped_traces
ORDER BY __result_0 DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// A trace-level filter qualifies first: __qualified holds the trace ids whose
// whole-window value passes, and the per-trace scan is constrained to them.
func TestBuild_FullSQL_Scalar_TraceFilter(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __qualified AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
HAVING output_tokens > 1000
),
__scoped_traces AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
GROUP BY trace_id
)
SELECT avg(output_tokens) AS __result_0
FROM __scoped_traces
ORDER BY __result_0 DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Grouping by an intrinsic: the positional alias keeps `toString(name) AS name` (a cyclic
// alias) from forming, and an order key on the dimension resolves to that alias.
func TestBuild_FullSQL_Scalar_GroupByIntrinsic(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "name"}}},
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "name"}}, Direction: qbtypes.OrderDirectionAsc}},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __scoped_traces AS (
SELECT trace_id,
toString(multiIf(name <> '', toString(name), NULL)) AS __GROUP_BY_KEY_0_name,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id, __GROUP_BY_KEY_0_name
)
SELECT __GROUP_BY_KEY_0_name, avg(output_tokens) AS __result_0
FROM __scoped_traces
GROUP BY __GROUP_BY_KEY_0_name
ORDER BY __GROUP_BY_KEY_0_name asc
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Every dimension at once; the HAVING on the alias is rewritten to __result_0.
func TestBuild_FullSQL_Scalar_FullCombo(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{Expression: "avg(trace.output_tokens)", Alias: "avg_out"},
{Expression: "count(trace.trace_id)"},
},
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND trace.total_tokens > 100"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}}},
Having: &qbtypes.Having{Expression: "avg_out > 50"},
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "avg_out"}}, Direction: qbtypes.OrderDirectionDesc}},
Limit: 5,
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __qualified AS (
SELECT trace_id,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
HAVING total_tokens > 100
),
__scoped_traces AS (
SELECT trace_id,
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model
)
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, avg(output_tokens) AS __result_0, count(trace_id) AS __result_1
FROM __scoped_traces
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model
HAVING __result_0 > 50
ORDER BY __result_0 desc
LIMIT 5
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Time series: the per-trace scan buckets by span time, the outer aggregation per bucket.
func TestBuild_FullSQL_TimeSeries_TraceAgg(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __scoped_traces AS (
SELECT trace_id,
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id, ts
)
SELECT ts, avg(output_tokens) AS __result_0
FROM __scoped_traces
GROUP BY ts
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// A grouped, limited time series ranks groups on unbucketed whole-window values
// (__scoped_traces_total), so a non-composable aggregate like avg ranks exactly.
func TestBuild_FullSQL_TimeSeries_GroupLimit(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(trace.output_tokens)", Alias: "total_out"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}}},
Having: &qbtypes.Having{Expression: "total_out > 500"},
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "total_out"}}, Direction: qbtypes.OrderDirectionDesc}},
Limit: 3,
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __scoped_traces_total AS (
SELECT trace_id,
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model
),
__limit_cte AS (
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, sum(output_tokens) AS __result_0
FROM __scoped_traces_total
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model
ORDER BY __result_0 desc
LIMIT 3
),
__scoped_traces AS (
SELECT trace_id,
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND (toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL))) GLOBAL IN (SELECT __GROUP_BY_KEY_0_gen_ai.request.model FROM __limit_cte)
GROUP BY trace_id, ts, __GROUP_BY_KEY_0_gen_ai.request.model
)
SELECT ts, __GROUP_BY_KEY_0_gen_ai.request.model, sum(output_tokens) AS __result_0
FROM __scoped_traces
GROUP BY ts, __GROUP_BY_KEY_0_gen_ai.request.model
HAVING __result_0 > 500
ORDER BY ts desc
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// A span-level scalar delegates to the trace builder, constrained by __trace_scope;
// the shape is the delegate's own, hence no SETTINGS suffix.
func TestBuild_FullSQL_Scalar_SpanAgg_TraceScoped(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(gen_ai.usage.output_tokens)"}},
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __trace_scope AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
HAVING output_tokens > 1000
)
SELECT sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS __result_0
FROM signoz_traces.distributed_signoz_index_v3
WHERE trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
ORDER BY __result_0 DESC
`, stmt)
}
// Two group keys make the top-N prune a 2-tuple GLOBAL IN, and the qualification plus
// span predicate apply to the ranking scan and the main scan alike.
func TestBuild_FullSQL_TimeSeries_GroupLimit_MultiKey(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "sum(trace.output_tokens)"},
{Expression: "count(trace.trace_id)"},
},
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND trace.total_tokens > 100"},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.user.id"}},
},
Limit: 2,
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __qualified AS (
SELECT trace_id,
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
HAVING total_tokens > 100
),
__scoped_traces_total AS (
SELECT trace_id,
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL)) AS __GROUP_BY_KEY_1_gen_ai.user.id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
),
__limit_cte AS (
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id, sum(output_tokens) AS __result_0, count(trace_id) AS __result_1
FROM __scoped_traces_total
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
ORDER BY __result_0 DESC
LIMIT 2
),
__scoped_traces AS (
SELECT trace_id,
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL)) AS __GROUP_BY_KEY_1_gen_ai.user.id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
AND (toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)), toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL))) GLOBAL IN (SELECT __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id FROM __limit_cte)
GROUP BY trace_id, ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
)
SELECT ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id, sum(output_tokens) AS __result_0, count(trace_id) AS __result_1
FROM __scoped_traces
GROUP BY ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// A time-series limit without group-by has nothing to rank: it is ignored, matching
// the trace builder — the query equals its unlimited form.
func TestBuild_TimeSeries_LimitWithoutGroupByIgnored(t *testing.T) {
b := newTestBuilder(t)
build := func(limit int) *qbtypes.Statement {
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
Limit: limit,
}, nil)
require.NoError(t, err)
return stmt
}
assert.Equal(t, build(0).Query, build(5).Query)
}
// ---------------------------------------------------------------------------
// Behavior / branch tests not covered by the goldens above
// ---------------------------------------------------------------------------
// Mixing span- and trace-level aggregations across one query is rejected.
func TestBuild_Aggregation_MixedDomainsRejected(t *testing.T) {
b := newTestBuilder(t)
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{Expression: "avg(trace.output_tokens)"},
{Expression: "sum(gen_ai.usage.output_tokens)"},
},
}, nil)
require.ErrorContains(t, err, "cannot be mixed")
}
// Output-only aggregates are rejected in trace-level filters on the aggregation
// path too (the raw and trace-list paths are covered elsewhere).
func TestBuild_Aggregation_OutputOnlyFilterRejected(t *testing.T) {
b := newTestBuilder(t)
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{Expression: "trace.span_count > 3"},
}, nil)
require.ErrorContains(t, err, `aggregate "span_count" cannot be used`)
}
// Trace-level columns are rejected as group-by keys; order keys never reach the builder,
// since request validation only admits group keys and aggregation aliases/expressions.
func TestBuild_Aggregation_GroupByOrderValidation(t *testing.T) {
b := newTestBuilder(t)
ctx := context.Background()
_, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.llm_call_count"}}},
}, nil)
require.ErrorContains(t, err, `grouping by trace-level aggregate "trace.llm_call_count" is not supported`)
req := qbtypes.QueryRangeRequest{
Start: testStartMs,
End: testEndMs,
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.total_tokens"}}, Direction: qbtypes.OrderDirectionDesc}},
},
}},
},
}
require.ErrorContains(t, req.Validate(), "invalid order by key")
_, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)", Alias: "avg_out"}},
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "avg_out"}}, Direction: qbtypes.OrderDirectionAsc}},
}, nil)
require.NoError(t, err)
}
// Variables in trace-level conditions resolve as bound args; a dynamic __all__ drops the
// condition, and an unresolved $var is rejected only as an unknown aggregate today.
func TestBuild_FullSQL_Aggregation_VariablesInTraceFilter(t *testing.T) {
b := newTestBuilder(t)
ctx := context.Background()
q := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > $threshold"},
}
stmt, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q,
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.TextBoxVariableType, Value: float64(1000)}})
require.NoError(t, err)
assertSQLEqual(t, `
WITH __qualified AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
HAVING output_tokens > 1000
),
__scoped_traces AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
GROUP BY trace_id
)
SELECT avg(output_tokens) AS __result_0
FROM __scoped_traces
ORDER BY __result_0 DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
// an unresolved $var is only rejected as an unknown aggregate today; a targeted
// "unknown variable" error is a separate concern
_, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q, nil)
require.ErrorContains(t, err, `aggregate "$threshold" cannot be used`)
// __all__ drops the condition: the query equals its unfiltered form
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q,
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.DynamicVariableType, Value: "__all__"}})
require.NoError(t, err)
unfiltered := q
unfiltered.Filter = nil
want, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, unfiltered, nil)
require.NoError(t, err)
assert.Equal(t, want.Query, stmt.Query)
// list variables render as IN with bound args; the scan selects only trace_id
// since no aggregation touches a per-trace column
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "count(trace.trace_id)"}},
Filter: &qbtypes.Filter{Expression: "trace.llm_call_count IN $counts"},
}, map[string]qbtypes.VariableItem{
"counts": {Type: qbtypes.QueryVariableType, Value: []any{float64(1), float64(2)}},
})
require.NoError(t, err)
assertSQLEqual(t, `
WITH __qualified AS (
SELECT trace_id,
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
GROUP BY trace_id
HAVING llm_call_count IN (1, 2)
),
__scoped_traces AS (
SELECT trace_id
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
GROUP BY trace_id
)
SELECT count(trace_id) AS __result_0
FROM __scoped_traces
ORDER BY __result_0 DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// Resource conditions on the native path: the __resource_filter CTE prunes the
// qualification scan and the per-trace scan by fingerprint.
func TestBuild_FullSQL_Aggregation_ResourceFilter_Native(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
Filter: &qbtypes.Filter{Expression: "service.name = 'api' AND trace.output_tokens > 1000"},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __resource_filter AS (
SELECT fingerprint
FROM signoz_traces.distributed_traces_v3_resource
WHERE (simpleJSONExtractString(labels, 'service.name') = 'api' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"api%')
AND seen_at_ts_bucket_start >= 1747945619
AND seen_at_ts_bucket_start <= 1747983448
GROUP BY fingerprint
),
__qualified AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
GROUP BY trace_id
HAVING output_tokens > 1000
),
__scoped_traces AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
GROUP BY trace_id
)
SELECT avg(output_tokens) AS __result_0
FROM __scoped_traces
ORDER BY __result_0 DESC
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
`, stmt)
}
// On the delegated path __trace_scope and the main query share one __resource_filter
// CTE, so the resource table is scanned once.
func TestBuild_FullSQL_Aggregation_ResourceFilter_Delegated(t *testing.T) {
b := newTestBuilder(t)
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(gen_ai.usage.output_tokens)"}},
Filter: &qbtypes.Filter{Expression: "service.name = 'api' AND trace.output_tokens > 1000"},
}, nil)
require.NoError(t, err)
assertSQLEqual(t, `
WITH __resource_filter AS (
SELECT fingerprint
FROM signoz_traces.distributed_traces_v3_resource
WHERE ((simpleJSONExtractString(labels, 'service.name') = 'api' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"api%'))
AND seen_at_ts_bucket_start >= 1747945619
AND seen_at_ts_bucket_start <= 1747983448
GROUP BY fingerprint
),
__trace_scope AS (
SELECT trace_id,
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
FROM signoz_traces.distributed_signoz_index_v3
WHERE timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
GROUP BY trace_id
HAVING output_tokens > 1000
)
SELECT sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS __result_0
FROM signoz_traces.distributed_signoz_index_v3
WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
AND trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)
AND (((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AND ((multiIf(resource.service.name IS NOT NULL, resource.service.name::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = 'api' AND multiIf(resource.service.name IS NOT NULL, resource.service.name::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))
AND timestamp >= '1747947419000000000'
AND timestamp < '1747983448000000000'
AND ts_bucket_start >= 1747945619
AND ts_bucket_start <= 1747983448
ORDER BY __result_0 DESC
`, stmt)
}
// rate() divides by the window (scalar) / step (series). Per AggreFuncMap it counts
// per-trace rows per second; it does not sum the column.
func TestBuild_Aggregation_RateDividesByInterval(t *testing.T) {
b := newTestBuilder(t)
ctx := context.Background()
q := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "rate(trace.llm_call_count)"}},
}
stmt, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q, nil)
require.NoError(t, err)
assert.Contains(t, stmt.Query, "count(llm_call_count)/36029 AS __result_0") // (end-start) seconds
q.StepInterval = qbtypes.Step{Duration: 60 * time.Second}
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries, q, nil)
require.NoError(t, err)
assert.Contains(t, stmt.Query, "count(llm_call_count)/60 AS __result_0")
// a sub-second window clamps the divisor instead of truncating it to zero
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testStartMs+500, qbtypes.RequestTypeScalar, q, nil)
require.NoError(t, err)
assert.Contains(t, stmt.Query, "count(llm_call_count)/1 AS __result_0")
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -14,12 +15,15 @@ import (
type defaultConditionBuilder struct {
fm qbtypes.FieldMapper
// fl evaluates the resolve_semconv_families flag during resolution.
// A nil flagger keeps resolution literal.
fl flagger.Flagger
}
var _ qbtypes.ConditionBuilder = (*defaultConditionBuilder)(nil)
func NewConditionBuilder(fm qbtypes.FieldMapper) *defaultConditionBuilder {
return &defaultConditionBuilder{fm: fm}
func NewConditionBuilder(fm qbtypes.FieldMapper, fl flagger.Flagger) *defaultConditionBuilder {
return &defaultConditionBuilder{fm: fm, fl: fl}
}
func valueForIndexFilter(op qbtypes.FilterOperator, key *telemetrytypes.TelemetryFieldKey, value any) any {
@@ -44,10 +48,74 @@ func keyIndexFilter(key *telemetrytypes.TelemetryFieldKey) any {
return fmt.Sprintf(`%%%s%%`, key.Name)
}
// The three helpers below take the members of one logical field. With a single
// member they render exactly the pre-family shapes; a family widens key/value
// index hints to any-member and presence to any-member (all-absent when negated).
func keyIndexCondition(sb *sqlbuilder.SelectBuilder, column string, members []*telemetrytypes.TelemetryFieldKey) string {
conditions := make([]string, 0, len(members))
for _, member := range members {
conditions = append(conditions, sb.Like(column, keyIndexFilter(member)))
}
if len(conditions) == 1 {
return conditions[0]
}
return sb.Or(conditions...)
}
func valueIndexCondition(
sb *sqlbuilder.SelectBuilder,
column string,
members []*telemetrytypes.TelemetryFieldKey,
op qbtypes.FilterOperator,
value any,
caseInsensitive bool,
) string {
conditions := make([]string, 0, len(members))
for _, member := range members {
patterns := valueForIndexFilter(op, member, value)
switch values := patterns.(type) {
case []string:
for _, pattern := range values {
conditions = append(conditions, sb.Like(column, pattern))
}
default:
if caseInsensitive {
conditions = append(conditions, sb.ILike(column, values))
} else {
conditions = append(conditions, sb.Like(column, values))
}
}
}
if len(conditions) == 1 {
return conditions[0]
}
return sb.Or(conditions...)
}
func memberPresenceCondition(sb *sqlbuilder.SelectBuilder, column string, members []*telemetrytypes.TelemetryFieldKey, exists bool) string {
conditions := make([]string, 0, len(members))
for _, member := range members {
field := fmt.Sprintf("simpleJSONHas(%s, '%s')", column, member.Name)
if exists {
conditions = append(conditions, sb.E(field, true))
} else {
conditions = append(conditions, sb.NE(field, true))
}
}
if exists {
if len(conditions) == 1 {
return conditions[0]
}
return sb.Or(conditions...)
}
return sb.And(conditions...)
}
// SkipResourceFilter is not applicable here: the fingerprint table only stores resource attributes.
func (b *defaultConditionBuilder) ConditionFor(
ctx context.Context,
_ valuer.UUID,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
@@ -57,7 +125,7 @@ func (b *defaultConditionBuilder) ConditionFor(
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
matches := querybuilder.MatchingFieldKeys(key, fieldKeys)
matches := querybuilder.MatchingLogicalFields(ctx, orgID, b.fl, key, fieldKeys)
// has/hasAny/hasAll/hasToken are logs-body-only functions; they never apply to the
// resource fingerprint table, so skip them (the main query still evaluates them).
@@ -65,21 +133,21 @@ func (b *defaultConditionBuilder) ConditionFor(
return nil, nil, nil
}
keys, warning := querybuilder.ResolveKeys(key, matches)
logicalFields, warning := querybuilder.ResolveLogicalFields(key, matches)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
// the resource fingerprint table only stores resource attributes; keys from
conds := make([]string, 0, len(logicalFields))
for _, logical := range logicalFields {
// the resource fingerprint table only stores resource attributes; fields from
// any other context contribute no condition and are omitted. An empty result
// (including an unknown key) lets the caller skip this filter entirely.
if k.FieldContext != telemetrytypes.FieldContextResource {
if logical.FieldContext != telemetrytypes.FieldContextResource {
continue
}
cond, err := b.conditionForKey(ctx, startNs, endNs, k, op, value, sb)
cond, err := b.conditionForLogicalField(ctx, orgID, startNs, endNs, logical, op, value, sb)
if err != nil {
return nil, nil, err
}
@@ -88,11 +156,12 @@ func (b *defaultConditionBuilder) ConditionFor(
return conds, warnings, nil
}
func (b *defaultConditionBuilder) conditionForKey(
func (b *defaultConditionBuilder) conditionForLogicalField(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
logical *telemetrytypes.LogicalField,
op qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
@@ -102,7 +171,9 @@ func (b *defaultConditionBuilder) conditionForKey(
// as we store resource values as string
formattedValue := querybuilder.FormatValueForContains(value)
columns, err := b.fm.ColumnFor(ctx, valuer.UUID{}, startNs, endNs, key)
// Every resource-context key maps to the labels column, so any member
// resolves the column for the whole field.
columns, err := b.fm.ColumnFor(ctx, orgID, startNs, endNs, logical.Single())
if err != nil {
return "", err
}
@@ -115,10 +186,12 @@ func (b *defaultConditionBuilder) conditionForKey(
// as we have not changed the resource column in the resource fingerprint table.
column := columns[0]
keyIdxFilter := sb.Like(column.Name, keyIndexFilter(key))
valueForIndexFilter := valueForIndexFilter(op, key, value)
members := logical.Members
isFamily := logical.IsFamily()
keyIdxFilter := keyIndexCondition(sb, column.Name, members)
singleValueIndexFilter := valueForIndexFilter(op, members[0], value)
fieldName, err := b.fm.FieldFor(ctx, valuer.UUID{}, startNs, endNs, key)
fieldName, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, b.fm, logical)
if err != nil {
return "", err
}
@@ -128,12 +201,17 @@ func (b *defaultConditionBuilder) conditionForKey(
return sb.And(
sb.E(fieldName, formattedValue),
keyIdxFilter,
sb.Like(column.Name, valueForIndexFilter),
valueIndexCondition(sb, column.Name, members, op, value, false),
), nil
case qbtypes.FilterOperatorNotEqual:
if isFamily {
// A negated value-index hint would drop rows where another member
// holds the value; the fingerprint scan is small enough without it.
return sb.NE(fieldName, formattedValue), nil
}
return sb.And(
sb.NE(fieldName, formattedValue),
sb.NotLike(column.Name, valueForIndexFilter),
sb.NotLike(column.Name, singleValueIndexFilter),
), nil
case qbtypes.FilterOperatorGreaterThan:
return sb.And(sb.GT(fieldName, formattedValue), keyIdxFilter), nil
@@ -148,7 +226,7 @@ func (b *defaultConditionBuilder) conditionForKey(
return sb.And(
sb.ILike(fieldName, formattedValue),
keyIdxFilter,
sb.ILike(column.Name, valueForIndexFilter),
valueIndexCondition(sb, column.Name, members, op, value, true),
), nil
case qbtypes.FilterOperatorNotLike, qbtypes.FilterOperatorNotILike:
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else
@@ -185,13 +263,11 @@ func (b *defaultConditionBuilder) conditionForKey(
inConditions = append(inConditions, sb.E(fieldName, querybuilder.FormatValueForContains(v)))
}
mainCondition := sb.Or(inConditions...)
valConditions := make([]string, 0, len(values))
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
for _, v := range valuesForIndexFilter {
valConditions = append(valConditions, sb.Like(column.Name, v))
}
}
mainCondition = sb.And(mainCondition, keyIdxFilter, sb.Or(valConditions...))
mainCondition = sb.And(
mainCondition,
keyIdxFilter,
valueIndexCondition(sb, column.Name, members, op, value, false),
)
return mainCondition, nil
case qbtypes.FilterOperatorNotIn:
@@ -204,8 +280,13 @@ func (b *defaultConditionBuilder) conditionForKey(
notInConditions = append(notInConditions, sb.NE(fieldName, querybuilder.FormatValueForContains(v)))
}
mainCondition := sb.And(notInConditions...)
if isFamily {
// A negated value-index hint would drop rows where another member
// holds the value; the fingerprint scan is small enough without it.
return mainCondition, nil
}
valConditions := make([]string, 0, len(values))
if valuesForIndexFilter, ok := valueForIndexFilter.([]string); ok {
if valuesForIndexFilter, ok := singleValueIndexFilter.([]string); ok {
for _, v := range valuesForIndexFilter {
valConditions = append(valConditions, sb.NotLike(column.Name, v))
}
@@ -215,13 +296,11 @@ func (b *defaultConditionBuilder) conditionForKey(
case qbtypes.FilterOperatorExists:
return sb.And(
sb.E(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
memberPresenceCondition(sb, column.Name, members, true),
keyIdxFilter,
), nil
case qbtypes.FilterOperatorNotExists:
return sb.And(
sb.NE(fmt.Sprintf("simpleJSONHas(%s, '%s')", column.Name, key.Name), true),
), nil
return memberPresenceCondition(sb, column.Name, members, false), nil
case qbtypes.FilterOperatorRegexp:
return sb.And(
@@ -237,7 +316,7 @@ func (b *defaultConditionBuilder) conditionForKey(
return sb.And(
sb.ILike(fieldName, fmt.Sprintf(`%%%s%%`, formattedValue)),
keyIdxFilter,
sb.ILike(column.Name, valueForIndexFilter),
valueIndexCondition(sb, column.Name, members, op, value, true),
), nil
case qbtypes.FilterOperatorNotContains:
// no index filter: as cannot apply `not contains x%y` as y can be somewhere else

View File

@@ -2,6 +2,7 @@ package resourcefilter
import (
"context"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -201,7 +202,7 @@ func TestConditionBuilder(t *testing.T) {
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
conditionBuilder := NewConditionBuilder(fm, flaggertest.New(t))
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()

View File

@@ -0,0 +1,141 @@
package resourcefilter
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/require"
)
// The tests below run against the deployment.environment(.name) family with
// the resolve_semconv_families flag on, and with both spellings present in
// the metadata map as trace resource attributes.
func TestFamilyEqualWidensIndexHintsToAnyMember(t *testing.T) {
cb := NewConditionBuilder(NewFieldMapper(), flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true}))
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, 0, 0,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE (COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?))", sql)
require.Equal(t, []any{"production", "%deployment.environment.name%", "%deployment.environment%", "%deployment.environment.name\":\"production%", "%deployment.environment\":\"production%"}, args)
}
func TestFamilyNotEqualDropsNegatedValueHint(t *testing.T) {
cb := NewConditionBuilder(NewFieldMapper(), flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true}))
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, 0, 0,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotEqual, "production", sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') <> ?", sql)
require.Equal(t, []any{"production"}, args)
}
func TestFamilyExistsIsAnyMemberPresence(t *testing.T) {
cb := NewConditionBuilder(NewFieldMapper(), flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true}))
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, 0, 0,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorExists, nil, sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE ((simpleJSONHas(labels, 'deployment.environment.name') = ? OR simpleJSONHas(labels, 'deployment.environment') = ?) AND (labels LIKE ? OR labels LIKE ?))", sql)
require.Equal(t, []any{true, true, "%deployment.environment.name%", "%deployment.environment%"}, args)
sb = sqlbuilder.NewSelectBuilder()
conds, _, err = cb.ConditionFor(context.Background(), valuer.UUID{}, 0, 0,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotExists, nil, sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args = sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE (simpleJSONHas(labels, 'deployment.environment.name') <> ? AND simpleJSONHas(labels, 'deployment.environment') <> ?)", sql)
require.Equal(t, []any{true, true}, args)
}
// With only one member in metadata the SQL keeps the exact pre-family shape,
// including the negated value hint on !=.
func TestSingleMemberShapesUnchanged(t *testing.T) {
cb := NewConditionBuilder(NewFieldMapper(), flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureResolveSemconvFamilies.String(): true}))
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, 0, 0,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotEqual, "production", sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE (simpleJSONExtractString(labels, 'deployment.environment.name') <> ? AND labels NOT LIKE ?)", sql)
require.Equal(t, []any{"production", "%deployment.environment.name\":\"production%"}, args)
}

View File

@@ -71,6 +71,33 @@ func (m *defaultFieldMapper) FieldFor(
return columns[0].Name, nil
}
// ExistsFor reports key presence in the fingerprint labels JSON. Only resource
// context keys have a presence notion here; anything else is a real column and
// always present.
func (m *defaultFieldMapper) ExistsFor(
ctx context.Context,
_ valuer.UUID,
tsStart, tsEnd uint64,
key *telemetrytypes.TelemetryFieldKey,
exists bool,
) (string, error) {
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
if err != nil {
return "", err
}
if key.FieldContext != telemetrytypes.FieldContextResource {
if exists {
return "true", nil
}
return "false", nil
}
pred := fmt.Sprintf("simpleJSONHas(%s, '%s')", columns[0].Name, key.Name)
if exists {
return pred, nil
}
return "NOT " + pred, nil
}
func (m *defaultFieldMapper) ColumnExpressionFor(
ctx context.Context,
orgID valuer.UUID,

View File

@@ -47,7 +47,7 @@ func New[T any](
) *resourceFilterStatementBuilder[T] {
set := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter")
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
cb := NewConditionBuilder(fm, fl)
return &resourceFilterStatementBuilder[T]{
logger: set.Logger(),
dbName: dbName,
@@ -99,7 +99,7 @@ func (b *resourceFilterStatementBuilder[T]) Build(
q.Select("fingerprint")
q.From(fmt.Sprintf("%s.%s", b.dbName, b.tableName))
keySelectors := b.getKeySelectors(query)
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.flagger, b.getKeySelectors(query))
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
if err != nil {
return nil, err
@@ -164,6 +164,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
filterWhereClause, err := querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
OrgID: orgID,
Flagger: b.flagger,
Logger: b.logger,
FieldMapper: b.fieldMapper,
ConditionBuilder: b.conditionBuilder,

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -19,7 +18,6 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
qbvariables "github.com/SigNoz/signoz/pkg/variables"
"github.com/huandu/go-sqlbuilder"
)
@@ -38,6 +36,7 @@ type scopedTraceStatementBuilder struct {
scope TraceScope
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
fl flagger.Flagger
}
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
@@ -59,8 +58,8 @@ func NewFactory(
if err != nil {
return nil, err
}
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
return NewScopedTraceStatementBuilder(settings, metadataStore, fm, cb, scope, traceStmtBuilder, fl), nil
},
)
@@ -98,6 +97,7 @@ func NewScopedTraceStatementBuilder(
scope: scope,
traceStmtBuilder: traceStmtBuilder,
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
fl: fl,
}
}
@@ -115,6 +115,8 @@ func (b *scopedTraceStatementBuilder) Build(
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
case qbtypes.RequestTypeRaw:
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
case qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries:
return b.buildAggregation(ctx, orgID, start, end, requestType, query, variables)
default:
return nil, ErrUnsupportedRequestType
}
@@ -143,6 +145,63 @@ func (b *scopedTraceStatementBuilder) buildDelegated(
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
}
// traceScopedStatementBuilder is the delegate's optional capability of constraining a
// query to a set of trace ids (implemented by the traces statement builder).
// traceScopeResource is the __resource_filter CTE traceScope's predicate references,
// shared with the delegate's own resource filter so the table is scanned once.
type traceScopedStatementBuilder interface {
BuildTraceScoped(ctx context.Context, orgID valuer.UUID, start, end uint64, requestType qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], variables map[string]qbtypes.VariableItem, traceScope, traceScopeResource *qbtypes.Statement) (*qbtypes.Statement, error)
}
// buildDelegatedAggregation serves span-level scalar/time-series through the standard
// trace builder, with the gate ANDed into the span-level filter part; a trace-level
// part becomes a qualification the delegate constrains trace_id by.
func (b *scopedTraceStatementBuilder) buildDelegatedAggregation(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var spanExpr, traceExpr string
var err error
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
spanExpr, traceExpr, err = querybuilder.SplitFilterForAggregates(query.Filter.Expression, b.aggregateAliasSet())
if err != nil {
return nil, err
}
}
gate := b.scope.FilterExpression
expr := gate
if strings.TrimSpace(spanExpr) != "" {
expr = fmt.Sprintf("(%s) AND (%s)", gate, spanExpr)
}
// shallow copy; only Filter is replaced, caller's query untouched
gated := query
gated.Filter = &qbtypes.Filter{Expression: expr}
if strings.TrimSpace(traceExpr) == "" {
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
}
scoped, ok := b.traceStmtBuilder.(traceScopedStatementBuilder)
if !ok {
return nil, errors.NewInternalf(errors.CodeInternal, "trace statement builder does not support trace-scoped queries")
}
scope, scopeResource, err := b.buildQualifiedStatement(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), traceExpr, query, variables)
if err != nil {
return nil, err
}
if scope == nil {
// every trace-level condition was dropped by variable resolution
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
}
return scoped.BuildTraceScoped(ctx, orgID, start, end, requestType, gated, variables, scope, scopeResource)
}
// buildTraceListQuery wires the CTE pipeline (start/end are nanoseconds):
// matched (windowed, mask-pruned top-N trace_ids) → ranked (their [start,end] from
// the summary table) → buckets (ts_bucket_start prune) → enrichment (every per-trace
@@ -164,9 +223,13 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
limit = 100
}
filterExpr := ""
if query.Filter != nil {
filterExpr = query.Filter.Expression
}
// Condition args bind into the builder an expression is embedded in, so the
// matched and enrichment passes each resolve against their own builder.
keys, err := b.fetchKeys(ctx, orgID)
keys, err := b.fetchKeys(ctx, orgID, spanFilterSelectors(filterExpr)...)
if err != nil {
return nil, err
}
@@ -184,23 +247,17 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
if err != nil {
return nil, err
}
orderableSet := orderableAliasSet(resolved)
filterableSet := filterableAliasSet(resolved)
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
if err != nil {
return nil, err
}
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), filterableSet, start, end, variables, matchedSB)
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), keys, start, end, variables, matchedSB)
if err != nil {
return nil, err
}
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, filterableSet, maskExpr, fp, resourcePred, limit, query.Offset)
if err != nil {
return nil, err
}
matchedFrag, matchedArgs := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, maskExpr, fp, resourcePred, limit, query.Offset)
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
@@ -256,9 +313,10 @@ func (b *scopedTraceStatementBuilder) maybeAttachResourceFilter(
"resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)", nil
}
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID, extra ...*telemetrytypes.FieldKeySelector) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
fields := b.resolverFieldKeys()
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields)+len(extra))
selectors = append(selectors, extra...)
for _, k := range fields {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
Name: k.Name,
@@ -267,7 +325,7 @@ func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID value
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
})
}
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.fl, selectors))
return keys, err
}
@@ -327,10 +385,9 @@ func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID val
}
type resolvedColumn struct {
alias string
expr string
orderable bool
filterable bool
alias string
expr string
orderable bool
}
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, cols *columnResolver, preds *predicateResolver) ([]resolvedColumn, error) {
@@ -340,7 +397,7 @@ func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID
if err != nil {
return nil, err
}
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable, filterable: c.Filterable})
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable})
}
return out, nil
}
@@ -382,29 +439,30 @@ func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy,
return orders, nil
}
// filterParts is the user filter split into a span-level predicate and a trace-level
// HAVING expression.
// filterParts is the user filter split into a span-level predicate and the resolved
// trace-level HAVING (nil when there is none).
type filterParts struct {
spanPred string
hasSpanFilter bool
havingExpr string
having *traceHaving
warnings []string
warningsURL string
}
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
// and a trace-level HAVING (explicit query.Having ANDed on), then validates the
// trace-level part against the matched-pass aggregates.
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, filterableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
// splitFilter splits query.Filter into a span-level predicate and a trace-level
// HAVING (explicit query.Having ANDed on before resolution); args bind into sb.
// keys must cover the filter's span-level selectors.
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet map[string]struct{}, keys map[string][]*telemetrytypes.TelemetryFieldKey, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
var fp filterParts
havingExpr := ""
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
if err != nil {
return fp, err
}
fp.havingExpr = traceExpr
havingExpr = traceExpr
if strings.TrimSpace(spanExpr) != "" {
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sb)
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, keys, variables, sb)
if err != nil {
return fp, err
}
@@ -417,40 +475,27 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID val
}
}
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
if fp.havingExpr != "" {
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
if havingExpr != "" {
havingExpr = fmt.Sprintf("(%s) AND (%s)", havingExpr, query.Having.Expression)
} else {
fp.havingExpr = query.Having.Expression
havingExpr = query.Having.Expression
}
}
// the HAVING is a plain text rewrite, so substitute variables here
if strings.TrimSpace(fp.havingExpr) != "" && len(variables) > 0 {
replaced, err := qbvariables.ReplaceVariablesInExpression(fp.havingExpr, variables)
if err != nil {
return fp, err
}
fp.havingExpr = replaced
}
if err := validateAggregateFilter(fp.havingExpr, filterableSet); err != nil {
having, err := b.resolveTraceHaving(ctx, havingExpr, variables, sb)
if err != nil {
return fp, err
}
fp.having = having
return fp, nil
}
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean
// predicate, args bound into sb.
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (string, []string, string, error) {
selectors := querybuilder.QueryStringToKeysSelectors(expr)
for i := range selectors {
selectors[i].Signal = telemetrytypes.SignalTraces
}
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
if err != nil {
return "", nil, "", err
}
// predicate, args bound into sb; keys must cover the expression's selectors.
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, keys map[string][]*telemetrytypes.TelemetryFieldKey, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (string, []string, string, error) {
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
Context: ctx,
OrgID: orgID,
Flagger: b.fl,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
@@ -475,8 +520,8 @@ func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context,
// span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the aliases ORDER BY
// / HAVING reference. Expressions carry $n markers bound to sb, so each can appear
// several times and every occurrence resolves to the same arg.
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet, filterableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any) {
needed := neededMatchedAliases(orders, fp.having)
selects := []string{"trace_id"}
for _, rc := range resolved {
if _, ok := needed[rc.alias]; !ok {
@@ -513,22 +558,8 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
having = append(having, "countIf("+maskExpr+") > 0")
having = append(having, "countIf("+fp.spanPred+") > 0")
}
if strings.TrimSpace(fp.havingExpr) != "" {
// the rewriter matches raw key text, so map the trace. form alongside the bare name
columnMap := make(map[string]string, len(filterableSet)*2)
for a := range filterableSet {
columnMap[a] = quoteAlias(a)
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
}
hv, err := querybuilder.NewHavingExpressionRewriter().Rewrite(fp.havingExpr, columnMap)
if err != nil {
return "", nil, err
}
if hv != "" {
// escape user text so a literal $ isn't read as an arg marker; the countIf
// entries hold live $n markers and must stay unescaped
having = append(having, sqlbuilder.Escape(hv))
}
if fp.having != nil {
having = append(having, fp.having.pred)
}
if len(having) > 0 {
sb.Having(strings.Join(having, " AND "))
@@ -541,7 +572,7 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
}
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return fmt.Sprintf("matched AS (%s)", sql), args, nil
return fmt.Sprintf("matched AS (%s)", sql), args
}
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace from the
@@ -582,8 +613,9 @@ func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(sb *sqlbuilder.Selec
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
// aggregateAliasSet is every trace-level column alias, used to classify filter keys;
// SpanLevel columns are filtered span-level, so skip them.
// aggregateAliasSet recognises trace-level keys — display-only aliases included, so one
// gets a targeted error instead of falling through as a span attribute (what a predicate
// may actually use is filterableColumnSet). SpanLevel columns are filtered span-level.
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
set := make(map[string]struct{}, len(b.scope.Columns))
for _, c := range b.scope.Columns {
@@ -594,70 +626,36 @@ func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
return set
}
// orderableAliasSet is the subset of aliases computable in the matched pass.
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
set := make(map[string]struct{})
for _, rc := range resolved {
if rc.orderable {
set[rc.alias] = struct{}{}
}
}
return set
}
// filterableAliasSet is the subset of aliases usable in the trace-level filter.
func filterableAliasSet(resolved []resolvedColumn) map[string]struct{} {
set := make(map[string]struct{})
for _, rc := range resolved {
if rc.filterable {
set[rc.alias] = struct{}{}
}
}
return set
}
// neededMatchedAliases is the minimal alias set the matched pass must select: those
// in ORDER BY plus those in the aggregate HAVING.
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
// in ORDER BY plus those the resolved trace-level HAVING touches.
func neededMatchedAliases(orders []listOrder, having *traceHaving) map[string]struct{} {
needed := make(map[string]struct{})
for _, o := range orders {
needed[o.alias] = struct{}{}
}
for _, name := range traceAggregateNames(havingExpr) {
if _, ok := orderableSet[name]; ok {
if having != nil {
for name := range having.used {
needed[name] = struct{}{}
}
}
return needed
}
// traceAggregateNames extracts the aggregate names a trace-level HAVING references;
// only unspecified- and trace-context selectors name aggregates.
func traceAggregateNames(havingExpr string) []string {
var names []string
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
if sel.FieldContext == telemetrytypes.FieldContextUnspecified || sel.FieldContext == telemetrytypes.FieldContextTrace {
names = append(names, sel.Name)
}
}
return names
}
// validateAggregateFilter rejects a trace-level filter referencing an aggregate that
// is not filterable.
// validateAggregateFilter rejects filters on aggregates that are not filterable
// (e.g. span_count) upfront, since inside the where-clause visitor the error would
// surface only as a detail of a combined one. Only unspecified- and trace-context
// selectors name aggregates.
func validateAggregateFilter(havingExpr string, filterableSet map[string]struct{}) error {
if strings.TrimSpace(havingExpr) == "" {
return nil
}
allowed := make([]string, 0, len(filterableSet))
for a := range filterableSet {
allowed = append(allowed, a)
}
sort.Strings(allowed)
for _, name := range traceAggregateNames(havingExpr) {
if _, ok := filterableSet[name]; !ok {
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != telemetrytypes.FieldContextTrace {
continue
}
if _, ok := filterableSet[sel.Name]; !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
"aggregate %q cannot be used in a trace-level filter; filterable aggregates: %s", sel.Name, strings.Join(sortedAliases(filterableSet), ", "))
}
}
return nil
@@ -672,6 +670,19 @@ func orderClause(orders []listOrder) []string {
return append(out, "trace_id DESC")
}
// spanFilterSelectors are the metadata selectors for every key a filter expression
// references, for batching into a single GetKeysMulti fetch.
func spanFilterSelectors(expr string) []*telemetrytypes.FieldKeySelector {
if strings.TrimSpace(expr) == "" {
return nil
}
selectors := querybuilder.QueryStringToKeysSelectors(expr)
for i := range selectors {
selectors[i].Signal = telemetrytypes.SignalTraces
}
return selectors
}
// quoteAlias backticks an alias containing characters special to the SQL builder.
func quoteAlias(alias string) string {
if strings.ContainsAny(alias, ".$`") {

View File

@@ -0,0 +1,783 @@
package scopedtracesstatementbuilder
import (
"context"
"fmt"
"sort"
"strings"
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
// The per-trace values these aggregations read are window-clipped and span-filtered,
// unlike the list's enrichment pass over every span of the whole trace, so the same
// column reads differently in each.
// traceAggregation is one aggregation rewritten to run over the per-trace scan.
type traceAggregation struct {
expr string // rewritten SQL over the per-trace column aliases
used map[string]struct{} // per-trace aliases referenced
isRate bool
}
// buildAggregation routes by aggregation domain: bare keys delegate to the standard
// trace builder, trace.-prefixed aggregates run over the per-trace scan.
func (b *scopedTraceStatementBuilder) buildAggregation(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
traceAggs, err := b.classifyAggregations(query.Aggregations)
if err != nil {
return nil, err
}
if err := b.validateGroupBy(query); err != nil {
return nil, err
}
if len(traceAggs) == 0 {
return b.buildDelegatedAggregation(ctx, orgID, start, end, requestType, query, variables)
}
return b.buildTraceAggregationQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), requestType, query, variables, traceAggs)
}
// classifyAggregations returns the rewritten trace-domain aggregations, nil when all
// are span-domain; mixing the two domains is rejected.
func (b *scopedTraceStatementBuilder) classifyAggregations(aggs []qbtypes.TraceAggregation) ([]traceAggregation, error) {
// permission, not recognition: unknown names are reported against exactly this set
traceCols := b.orderableColumnSet()
var out []traceAggregation
spanCount := 0
for _, agg := range aggs {
ta, isTrace, err := rewriteTraceAggregation(agg.Expression, traceCols)
if err != nil {
return nil, err
}
if isTrace {
out = append(out, *ta)
} else {
spanCount++
}
}
if len(out) > 0 && spanCount > 0 {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"span-level and trace-level (trace.) aggregations cannot be mixed in one query")
}
return out, nil
}
// orderableColumnSet is what a trace-level aggregation may use;
// recognising a key as trace-level is aggregateAliasSet's job.
func (b *scopedTraceStatementBuilder) orderableColumnSet() map[string]struct{} {
set := make(map[string]struct{})
for _, c := range b.scope.Columns {
if c.Orderable {
set[c.Alias] = struct{}{}
}
}
return set
}
// filterableColumnSet is what a trace-level filter predicate may use.
func (b *scopedTraceStatementBuilder) filterableColumnSet() map[string]struct{} {
set := make(map[string]struct{})
for _, c := range b.scope.Columns {
if c.Filterable {
set[c.Alias] = struct{}{}
}
}
return set
}
// validateGroupBy rejects trace-level columns as group-by keys with a targeted error
// (not the field mapper's generic "field not found"). Order keys need no check here:
// request validation only admits group keys and aggregation aliases/expressions.
func (b *scopedTraceStatementBuilder) validateGroupBy(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) error {
// recognition, not permission: a display-only alias must be named here to be rejected
// rather than reaching the field mapper as a span attribute
aliases := b.aggregateAliasSet()
for _, gb := range query.GroupBy {
key := gb.TelemetryFieldKey
key.Normalize()
// a bare name may be a span column sharing the alias (duration_nano, timestamp)
if key.FieldContext != telemetrytypes.FieldContextTrace {
continue
}
if _, ok := aliases[key.Name]; ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"grouping by trace-level aggregate %q is not supported; group by span attributes instead (e.g. service.name)", gb.Name)
}
}
return nil
}
// rewriteTraceAggregation rewrites an aggregation over trace.-prefixed columns to run
// on the per-trace scan (trace.output_tokens → output_tokens, functions mapped via
// AggreFuncMap); a pure span-level expression returns isTrace=false for the delegate.
func rewriteTraceAggregation(expr string, traceCols map[string]struct{}) (*traceAggregation, bool, error) {
p := chparser.NewParser("SELECT " + expr)
stmts, err := p.ParseStmts()
if err != nil {
return nil, false, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse aggregation expression %q", expr)
}
if len(stmts) == 0 {
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid aggregation expression %q", expr)
}
sel, ok := stmts[0].(*chparser.SelectQuery)
if !ok || len(sel.SelectItems) == 0 {
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid aggregation expression %q", expr)
}
v := &traceAggVisitor{traceCols: traceCols, used: make(map[string]struct{})}
if err := sel.SelectItems[0].Accept(v); err != nil {
return nil, false, err
}
if !v.hasTrace {
return nil, false, nil
}
if v.hasSpan {
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregation %q mixes trace-level (trace.) and span-level columns; use one domain per aggregation", expr)
}
// the interval divides the rendered expression as a whole, so a second aggregation
// alongside the rate would be divided too
if v.isRate && v.aggCount > 1 {
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregation %q combines a rate with another aggregation; the rate interval would divide both, so give each its own aggregation", expr)
}
return &traceAggregation{expr: chparser.Format(sel.SelectItems[0]), used: v.used, isRate: v.isRate}, true, nil
}
// traceAggVisitor classifies column references and rewrites trace.-prefixed ones in
// place; the ancestor stack tells a column identifier from a path segment, function
// name, or alias, and rejects trace. columns inside *If combinators.
type traceAggVisitor struct {
chparser.DefaultASTVisitor
traceCols map[string]struct{}
used map[string]struct{}
stack []chparser.Expr
aggCount int
hasTrace bool
hasSpan bool
isRate bool
}
func (v *traceAggVisitor) Enter(expr chparser.Expr) { v.stack = append(v.stack, expr) }
func (v *traceAggVisitor) Leave(expr chparser.Expr) { v.stack = v.stack[:len(v.stack)-1] }
// parent is the node enclosing the one currently being visited (the visited node
// itself is the stack top).
func (v *traceAggVisitor) parent() chparser.Expr {
if len(v.stack) < 2 {
return nil
}
return v.stack[len(v.stack)-2]
}
func (v *traceAggVisitor) enclosingCombinator() (string, bool) {
for _, e := range v.stack {
fn, ok := e.(*chparser.FunctionExpr)
if !ok {
continue
}
if agg, known := querybuilder.AggreFuncMap[valuer.NewString(strings.ToLower(fn.Name.Name))]; known && agg.FuncCombinator {
return fn.Name.Name, true
}
}
return "", false
}
// enclosingAggregate walks the ancestor stack; AggreFuncMap holds only aggregates and
// VisitFunctionExpr rejects any name missing from it, so a known name is enough.
func (v *traceAggVisitor) enclosingAggregate() bool {
for _, e := range v.stack {
fn, ok := e.(*chparser.FunctionExpr)
if !ok {
continue
}
if _, known := querybuilder.AggreFuncMap[valuer.NewString(strings.ToLower(fn.Name.Name))]; known {
return true
}
}
return false
}
// VisitPath classifies a dotted reference (trace.output_tokens); trace-level ones are
// rewritten in place to the bare per-trace alias.
func (v *traceAggVisitor) VisitPath(p *chparser.Path) error {
col, isTrace := traceColumnFromPath(p)
if !isTrace {
v.hasSpan = true
return nil
}
if err := v.acceptTraceColumn(chparser.Format(p), col); err != nil {
return err
}
p.Fields = p.Fields[len(p.Fields)-1:]
p.Fields[0].Name = col
return nil
}
// VisitIdent classifies a plain identifier (a backquoted `trace.output_tokens` is
// trace-level); path segments, function names, and aliases are structural, not columns.
func (v *traceAggVisitor) VisitIdent(i *chparser.Ident) error {
switch parent := v.parent().(type) {
case *chparser.Path:
return nil // segments are classified whole by VisitPath
case *chparser.FunctionExpr:
if parent.Name == i {
return nil
}
case *chparser.ColumnExpr:
if parent.Alias == i {
return nil
}
}
key := telemetrytypes.GetFieldKeyFromKeyText(i.Name)
if key.FieldContext != telemetrytypes.FieldContextTrace || key.Name == "" {
v.hasSpan = true
return nil
}
if err := v.acceptTraceColumn(i.Name, key.Name); err != nil {
return err
}
i.Name = key.Name
return nil
}
func (v *traceAggVisitor) acceptTraceColumn(ref, col string) error {
if name, in := v.enclosingCombinator(); in {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"%q over trace-level (trace.) columns is not supported; put the trace-level condition in the filter expression instead", name)
}
// trace_id is always selected by the per-trace scan (count(trace.trace_id)
// counts traces); everything else must be a scope column.
if col != "trace_id" {
if _, known := v.traceCols[col]; !known {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"unknown trace-level aggregation column %q; usable columns: %s", ref, strings.Join(sortedAliases(v.traceCols), ", "))
}
v.used[col] = struct{}{}
}
// ungrouped, a bare per-trace column would make the outer SELECT emit one row per
// trace instead of one aggregated row
if !v.enclosingAggregate() {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"trace-level column %q must be inside an aggregation function (e.g. avg(%s))", ref, ref)
}
v.hasTrace = true
return nil
}
// VisitFunctionExpr validates and maps the function name. Children were already
// visited (post-order), so classification is complete for this subtree.
func (v *traceAggVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
name := strings.ToLower(fn.Name.Name)
aggFunc, ok := querybuilder.AggreFuncMap[valuer.NewString(name)]
if !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unrecognized function: %s", name)
}
if fn.Params != nil && fn.Params.Items != nil && len(fn.Params.Items.Items) > 0 && aggFunc.FuncCombinator {
// combinator predicates over span columns stay span-level (countIf(has_error=true))
v.hasSpan = true
return nil
}
fn.Name.Name = aggFunc.FuncName
v.aggCount++
if aggFunc.Rate {
v.isRate = true
}
return nil
}
// traceColumnFromPath returns the per-trace column a dotted reference names
// (trace.output_tokens -> output_tokens, trace.a.b -> a.b).
func traceColumnFromPath(p *chparser.Path) (string, bool) {
key := telemetrytypes.GetFieldKeyFromKeyText(chparser.Format(p))
if key.FieldContext != telemetrytypes.FieldContextTrace || key.Name == "" {
return "", false
}
return key.Name, true
}
func sortedAliases(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for a := range set {
out = append(out, a)
}
sort.Strings(out)
return out
}
// ---------------------------------------------------------------------------
// Qualification + per-trace scan
// ---------------------------------------------------------------------------
// buildQualifiedStatement selects the trace ids whose window-clipped aggregates satisfy
// the trace-level filter. The second statement (nil without resource conditions) is the
// __resource_filter CTE the scope's predicate references; the embedder emits it exactly
// once, shared with its own resource filter. start/end are ns; both statements are nil
// when variable resolution dropped every condition.
func (b *scopedTraceStatementBuilder) buildQualifiedStatement(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
traceExpr string,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, *qbtypes.Statement, error) {
keys, err := b.fetchKeys(ctx, orgID)
if err != nil {
return nil, nil, err
}
sb := sqlbuilder.NewSelectBuilder()
maskExpr, resolved, err := b.resolveFor(ctx, orgID, start, end, keys, sb)
if err != nil {
return nil, nil, err
}
having, err := b.resolveTraceHaving(ctx, traceExpr, variables, sb)
if err != nil {
return nil, nil, err
}
if having == nil {
return nil, nil, nil
}
// nil when the filter has no resource-attribute conditions
resourceStmt, err := b.resourceFilterStmtBuilder.Build(ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables)
if err != nil {
return nil, nil, err
}
var resourcePred string
if resourceStmt != nil {
resourcePred = "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)"
}
sql, args := b.buildPerTraceScan(sb, start, end, resolved, maskExpr, perTraceScanOpts{
needed: having.used,
havingPred: having.pred,
resourcePred: resourcePred,
})
return &qbtypes.Statement{Query: sql, Args: args}, resourceStmt, nil
}
// groupColumn holds a resolved, arg-free span-attribute expression.
type groupColumn struct {
alias string
expr string
}
// groupByColumnAlias prefixes the i-th group-by dimension so the alias cannot shadow the
// span column its expression reads; the querier (stripKeyAlias) strips it back off.
func groupByColumnAlias(i int, name string) string {
return fmt.Sprintf("__GROUP_BY_KEY_%d_%s", i, name)
}
// orderColumn is the SQL identifier a non-aggregation order key sorts by: the
// positional alias when the key names a group-by dimension, else the key itself.
func orderColumn(orderKey string, groupBy []qbtypes.GroupByKey) string {
for i := range groupBy {
if groupBy[i].Name == orderKey {
return groupByColumnAlias(i, groupBy[i].Name)
}
}
return orderKey
}
// perTraceScanOpts parametrize one windowed, mask-pruned GROUP BY trace_id scan.
// All expressions are already resolved against the scan's builder.
type perTraceScanOpts struct {
stepSeconds int64 // >0 → bucket per-trace values by time (ts column)
groupCols []groupColumn
needed map[string]struct{} // per-trace aliases to select
spanPred string // resolved span-level filter, ANDed per span
resourcePred string // resource-fingerprint prune (CTE reference or inline subquery)
qualified bool // constrain to __qualified
limitPred string // top-N group prune (GLOBAL IN __limit_cte)
havingPred string // resolved HAVING predicate over the selected aliases
}
func (b *scopedTraceStatementBuilder) buildPerTraceScan(sb *sqlbuilder.SelectBuilder, start, end uint64, resolved []resolvedColumn, maskExpr string, o perTraceScanOpts) (string, []any) {
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
endBucket := end / querybuilder.NsToSeconds
selects := []string{"trace_id"}
if o.stepSeconds > 0 {
selects = append(selects, fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts", o.stepSeconds))
}
for _, gc := range o.groupCols {
selects = append(selects, fmt.Sprintf("toString(%s) AS `%s`", gc.expr, gc.alias))
}
for _, rc := range resolved {
if _, ok := o.needed[rc.alias]; !ok {
continue
}
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
}
sb.Select(selects...)
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
where := []string{
sb.GE("timestamp", fmt.Sprintf("%d", start)),
sb.L("timestamp", fmt.Sprintf("%d", end)),
sb.GE("ts_bucket_start", startBucket),
sb.LE("ts_bucket_start", endBucket),
maskExpr,
}
if strings.TrimSpace(o.spanPred) != "" {
where = append(where, o.spanPred)
}
if o.resourcePred != "" {
where = append(where, o.resourcePred)
}
if o.qualified {
where = append(where, "trace_id GLOBAL IN (SELECT trace_id FROM __qualified)")
}
if o.limitPred != "" {
where = append(where, o.limitPred)
}
sb.Where(where...)
groupBy := []string{"trace_id"}
if o.stepSeconds > 0 {
groupBy = append(groupBy, "ts")
}
for _, gc := range o.groupCols {
groupBy = append(groupBy, "`"+gc.alias+"`")
}
sb.GroupBy(groupBy...)
if strings.TrimSpace(o.havingPred) != "" {
sb.Having(o.havingPred)
}
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
// groupBySelectors are the metadata selectors for the group-by keys, for batching
// into a single GetKeysMulti fetch.
func groupBySelectors(groupBy []qbtypes.GroupByKey) []*telemetrytypes.FieldKeySelector {
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(groupBy))
for i := range groupBy {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
Name: groupBy[i].Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: groupBy[i].FieldContext,
FieldDataType: groupBy[i].FieldDataType,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
})
}
return selectors
}
// resolveGroupColumns resolves group-by keys through the field mapper for selection
// inside the per-trace scan; keys must cover the group-by selectors.
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, error) {
if len(groupBy) == 0 {
return nil, nil
}
out := make([]groupColumn, 0, len(groupBy))
for i := range groupBy {
expr, err := b.fm.ColumnExpressionFor(ctx, orgID, start, end, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
return nil, err
}
out = append(out, groupColumn{alias: groupByColumnAlias(i, groupBy[i].Name), expr: sqlbuilder.Escape(expr)})
}
return out, nil
}
// ---------------------------------------------------------------------------
// Native trace-domain aggregation query
// ---------------------------------------------------------------------------
// scanContext is one per-scan resolution: a fresh builder with the mask, columns,
// span predicate, and optionally the trace-level HAVING resolved against it.
type scanContext struct {
sb *sqlbuilder.SelectBuilder
maskExpr string
resolved []resolvedColumn
spanPred string
having *traceHaving
warnings []string
warnURL string
}
func (b *scopedTraceStatementBuilder) newScanContext(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
spanExpr, traceExpr string,
variables map[string]qbtypes.VariableItem,
) (*scanContext, error) {
sc := &scanContext{sb: sqlbuilder.NewSelectBuilder()}
var err error
sc.maskExpr, sc.resolved, err = b.resolveFor(ctx, orgID, start, end, keys, sc.sb)
if err != nil {
return nil, err
}
if strings.TrimSpace(spanExpr) != "" {
pred, warns, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, keys, variables, sc.sb)
if err != nil {
return nil, err
}
sc.spanPred, sc.warnings, sc.warnURL = pred, warns, url
}
if strings.TrimSpace(traceExpr) != "" {
sc.having, err = b.resolveTraceHaving(ctx, traceExpr, variables, sc.sb)
if err != nil {
return nil, err
}
}
return sc, nil
}
// buildTraceAggregationQuery aggregates over the per-trace scan: __qualified (when the
// filter has a trace-level part) → __scoped_traces → outer aggregation. start/end are ns.
func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
traceAggs []traceAggregation,
) (*qbtypes.Statement, error) {
var spanExpr, traceExpr string
var err error
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
// the broad set so a condition on a display-only alias still lands in the
// trace-level part, where resolveTraceHaving rejects it by name
spanExpr, traceExpr, err = querybuilder.SplitFilterForAggregates(query.Filter.Expression, b.aggregateAliasSet())
if err != nil {
return nil, err
}
}
keys, err := b.fetchKeys(ctx, orgID, append(spanFilterSelectors(spanExpr), groupBySelectors(query.GroupBy)...)...)
if err != nil {
return nil, err
}
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
if err != nil {
return nil, err
}
var cteFragments []string
var cteArgs [][]any
if resourceFrag != "" {
cteFragments = append(cteFragments, resourceFrag)
cteArgs = append(cteArgs, resourceArgs)
}
// __qualified: its own scan resolution, HAVING = the trace-level filter part
qualified := false
if strings.TrimSpace(traceExpr) != "" {
qsc, err := b.newScanContext(ctx, orgID, start, end, keys, "", traceExpr, variables)
if err != nil {
return nil, err
}
if qsc.having != nil {
qsql, qargs := b.buildPerTraceScan(qsc.sb, start, end, qsc.resolved, qsc.maskExpr, perTraceScanOpts{
needed: qsc.having.used,
havingPred: qsc.having.pred,
resourcePred: resourcePred,
})
cteFragments = append(cteFragments, fmt.Sprintf("__qualified AS (%s)", qsql))
cteArgs = append(cteArgs, qargs)
qualified = true
}
}
groupCols, err := b.resolveGroupColumns(ctx, orgID, start, end, query.GroupBy, keys)
if err != nil {
return nil, err
}
groupNames := make([]string, 0, len(groupCols))
for _, gc := range groupCols {
groupNames = append(groupNames, "`"+gc.alias+"`")
}
needed := make(map[string]struct{})
for _, ta := range traceAggs {
for a := range ta.used {
needed[a] = struct{}{}
}
}
// a window or step under one second would truncate to a zero divisor
windowSeconds := max((end-start)/querybuilder.NsToSeconds, 1)
stepSeconds := int64(0)
rateInterval := windowSeconds
if requestType == qbtypes.RequestTypeTimeSeries {
stepSeconds = int64(query.StepInterval.Seconds())
rateInterval = max(uint64(stepSeconds), 1)
}
// outer aggregation over the per-trace rows
sb := sqlbuilder.NewSelectBuilder()
selects := []string{}
if stepSeconds > 0 {
selects = append(selects, "ts")
}
selects = append(selects, groupNames...)
for i, ta := range traceAggs {
selects = append(selects, fmt.Sprintf("%s AS __result_%d", ta.rendered(rateInterval), i))
}
sb.Select(selects...)
sb.From("__scoped_traces")
// grouped, limited time series → rank groups on whole-window per-trace values
// (exact for non-composable aggregates) and prune the main scan to the top-N.
limitPred := ""
if requestType == qbtypes.RequestTypeTimeSeries && query.Limit > 0 && len(groupCols) > 0 {
tsc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
if err != nil {
return nil, err
}
totalSQL, totalArgs := b.buildPerTraceScan(tsc.sb, start, end, tsc.resolved, tsc.maskExpr, perTraceScanOpts{
groupCols: groupCols,
needed: needed,
spanPred: tsc.spanPred,
resourcePred: resourcePred,
qualified: qualified,
})
cteFragments = append(cteFragments, fmt.Sprintf("__scoped_traces_total AS (%s)", totalSQL))
cteArgs = append(cteArgs, totalArgs)
limitSQL, limitArgs := outerLimitSQL(query, traceAggs, groupNames, windowSeconds)
cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", limitSQL))
cteArgs = append(cteArgs, limitArgs)
exprs := make([]string, 0, len(groupCols))
for _, gc := range groupCols {
exprs = append(exprs, "toString("+gc.expr+")")
}
limitPred = fmt.Sprintf("(%s) GLOBAL IN (SELECT %s FROM __limit_cte)",
strings.Join(exprs, ", "), strings.Join(groupNames, ", "))
}
msc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
if err != nil {
return nil, err
}
perTraceSQL, perTraceArgs := b.buildPerTraceScan(msc.sb, start, end, msc.resolved, msc.maskExpr, perTraceScanOpts{
stepSeconds: stepSeconds,
groupCols: groupCols,
needed: needed,
spanPred: msc.spanPred,
resourcePred: resourcePred,
qualified: qualified,
limitPred: limitPred,
})
cteFragments = append(cteFragments, fmt.Sprintf("__scoped_traces AS (%s)", perTraceSQL))
cteArgs = append(cteArgs, perTraceArgs)
groupBys := []string{}
if stepSeconds > 0 {
groupBys = append(groupBys, "ts")
}
groupBys = append(groupBys, groupNames...)
if len(groupBys) > 0 {
sb.GroupBy(groupBys...)
}
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
rewritten, err := querybuilder.NewHavingExpressionRewriter().RewriteForTraces(query.Having.Expression, query.Aggregations)
if err != nil {
return nil, err
}
sb.Having(sqlbuilder.Escape(rewritten))
}
if requestType == qbtypes.RequestTypeTimeSeries {
if len(query.Order) != 0 {
for _, orderBy := range query.Order {
if _, ok := traceAggOrderIndex(orderBy, query); !ok {
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
}
}
sb.OrderBy("ts desc")
}
} else {
for _, orderBy := range query.Order {
if idx, ok := traceAggOrderIndex(orderBy, query); ok {
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
} else {
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
}
}
if len(query.Order) == 0 {
sb.OrderBy("__result_0 DESC")
}
if query.Limit > 0 {
sb.Limit(query.Limit)
}
}
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
return &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: msc.warnings,
WarningsDocURL: msc.warnURL,
}, nil
}
// rendered divides a rate aggregation by the interval (step for time series, window
// length for scalar); the divisor applies to the whole expression, which holds only
// because a rate must be the sole aggregation.
func (ta traceAggregation) rendered(rateInterval uint64) string {
if ta.isRate {
return fmt.Sprintf("%s/%d", ta.expr, rateInterval)
}
return ta.expr
}
// outerLimitSQL ranks groups on whole-window per-trace values, so a non-composable
// aggregate (avg) ranks exactly rather than over bucketed rows.
func outerLimitSQL(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], traceAggs []traceAggregation, groupNames []string, windowSeconds uint64) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
selects := append([]string{}, groupNames...)
for i, ta := range traceAggs {
selects = append(selects, fmt.Sprintf("%s AS __result_%d", ta.rendered(windowSeconds), i))
}
sb.Select(selects...)
sb.From("__scoped_traces_total")
sb.GroupBy(groupNames...)
for _, orderBy := range query.Order {
if idx, ok := traceAggOrderIndex(orderBy, query); ok {
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
} else {
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
}
}
if len(query.Order) == 0 {
sb.OrderBy("__result_0 DESC")
}
sb.Limit(query.Limit)
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
// traceAggOrderIndex reports whether an order key refers to the i-th aggregation
// (by alias, expression, or index), mirroring the trace builder.
func traceAggOrderIndex(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (int, bool) {
for i, agg := range q.Aggregations {
if k.Key.Name == agg.Alias ||
k.Key.Name == agg.Expression ||
k.Key.Name == fmt.Sprintf("%d", i) {
return i, true
}
}
return 0, false
}

View File

@@ -0,0 +1,75 @@
package scopedtracesstatementbuilder
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRewriteTraceAggregation(t *testing.T) {
cols := map[string]struct{}{
"input_tokens": {}, "output_tokens": {}, "total_tokens": {}, "llm_call_count": {}, "max_llm_latency_ns": {},
}
cases := []struct {
name string
expr string
isTrace bool
want string // rewritten expr, only checked when isTrace
used []string
wantErr string
}{
{name: "avg trace col", expr: "avg(trace.output_tokens)", isTrace: true, want: "avg(output_tokens)", used: []string{"output_tokens"}},
{name: "sum trace col", expr: "sum(trace.total_tokens)", isTrace: true, want: "sum(total_tokens)", used: []string{"total_tokens"}},
{name: "count traces", expr: "count(trace.trace_id)", isTrace: true, want: "count(trace_id)"},
{name: "p90 trace col", expr: "p90(trace.max_llm_latency_ns)", isTrace: true, want: "quantile(0.90)(max_llm_latency_ns)", used: []string{"max_llm_latency_ns"}},
{name: "arithmetic between trace cols", expr: "avg(trace.output_tokens + trace.input_tokens)", isTrace: true, want: "avg(output_tokens + input_tokens)", used: []string{"output_tokens", "input_tokens"}},
{name: "arithmetic with constant", expr: "sum(trace.output_tokens * 1.5)", isTrace: true, want: "sum(output_tokens * 1.5)", used: []string{"output_tokens"}},
{name: "ratio of two aggregations", expr: "sum(trace.output_tokens)/count(trace.trace_id)", isTrace: true, want: "sum(output_tokens) / count(trace_id)", used: []string{"output_tokens"}},
{name: "backquoted trace col", expr: "avg(`trace.output_tokens`)", isTrace: true, want: "avg(`output_tokens`)", used: []string{"output_tokens"}},
{name: "bare count is span-level", expr: "count()", isTrace: false},
{name: "span attribute is span-level", expr: "sum(gen_ai.usage.output_tokens)", isTrace: false},
{name: "countIf span predicate is span-level", expr: "countIf(has_error = true)", isTrace: false},
{name: "mixed domains in one expression", expr: "sum(trace.output_tokens) + sum(gen_ai.usage.input_tokens)", wantErr: "mixes trace-level"},
{name: "mixed domains in one function", expr: "sum(trace.output_tokens + gen_ai.usage.input_tokens)", wantErr: "mixes trace-level"},
{name: "output-only column rejected", expr: "avg(trace.span_count)", wantErr: "unknown trace-level aggregation column"},
{name: "unknown column rejected", expr: "avg(trace.bogus)", wantErr: "unknown trace-level aggregation column"},
// a dotted column keeps every segment after the prefix, so it is reported whole
{name: "multi segment column rejected by full name", expr: "avg(trace.service.name)", wantErr: `"trace.service.name"`},
{name: "bare trace identifier is span-level", expr: "avg(trace)", isTrace: false},
{name: "countIf over trace col rejected", expr: "countIf(trace.output_tokens > 1000)", wantErr: "not supported"},
{name: "bare trace col rejected", expr: "trace.output_tokens", wantErr: "must be inside an aggregation function"},
{name: "backquoted bare trace col rejected", expr: "`trace.output_tokens`", wantErr: "must be inside an aggregation function"},
{name: "bare trace_id rejected", expr: "trace.trace_id", wantErr: "must be inside an aggregation function"},
{name: "arithmetic outside an aggregation rejected", expr: "trace.output_tokens + trace.input_tokens", wantErr: "must be inside an aggregation function"},
{name: "trace col beside an aggregation rejected", expr: "sum(trace.output_tokens) + trace.input_tokens", wantErr: "must be inside an aggregation function"},
{name: "aggregation scaled by a constant", expr: "sum(trace.output_tokens) * 2", isTrace: true, want: "sum(output_tokens) * 2", used: []string{"output_tokens"}},
{name: "rate over traces", expr: "rate(trace.trace_id)", isTrace: true, want: "count(trace_id)"},
{name: "rate_sum trace col", expr: "rate_sum(trace.output_tokens)", isTrace: true, want: "sum(output_tokens)", used: []string{"output_tokens"}},
// the interval divides the whole rendered expression, so a second aggregation
// alongside a rate would be divided too
{name: "rate mixed with another aggregation rejected", expr: "rate(trace.trace_id) + avg(trace.output_tokens)", wantErr: "combines a rate with another aggregation"},
{name: "ratio of two rates rejected", expr: "rate_sum(trace.output_tokens)/rate_sum(trace.input_tokens)", wantErr: "combines a rate with another aggregation"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ta, isTrace, err := rewriteTraceAggregation(tc.expr, cols)
if tc.wantErr != "" {
require.ErrorContains(t, err, tc.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tc.isTrace, isTrace)
if !tc.isTrace {
return
}
assert.Equal(t, tc.want, ta.expr)
for _, u := range tc.used {
assert.Contains(t, ta.used, u)
}
assert.Len(t, ta.used, len(tc.used))
})
}
}

View File

@@ -0,0 +1,144 @@
package scopedtracesstatementbuilder
import (
"context"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
qbvariables "github.com/SigNoz/signoz/pkg/variables"
"github.com/huandu/go-sqlbuilder"
)
// traceHaving is the resolved trace-level filter part: a HAVING predicate over the
// per-trace aliases plus the aliases it references (so scans select only those).
type traceHaving struct {
pred string
used map[string]struct{}
}
// resolveTraceHaving runs a trace-level filter through the standard where-clause
// pipeline against the per-trace aliases, so operators, bound args, and __all__ behave
// as in span filters. Returns nil when nothing is left to filter; args bind into sb.
func (b *scopedTraceStatementBuilder) resolveTraceHaving(ctx context.Context, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (*traceHaving, error) {
if strings.TrimSpace(expr) == "" {
return nil, nil //nolint:nilnil
}
// replaced before validation so variable literals are not mistaken for aggregate
// names; an unresolved $var is left in place and fails validation as an unknown one
if len(variables) > 0 {
replaced, err := qbvariables.ReplaceVariablesInExpression(expr, variables)
if err != nil {
return nil, err
}
expr = replaced
if strings.TrimSpace(expr) == "" {
return nil, nil //nolint:nilnil
}
}
allowed := b.filterableColumnSet()
// upfront targeted errors; the visitor folds them into a combined "Found N errors"
if err := validateAggregateFilter(expr, allowed); err != nil {
return nil, err
}
// both spellings resolve here: the key parser strips the trace. prefix into
// FieldContextTrace, which matches this entry's context
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey, len(allowed))
for alias := range allowed {
key := &telemetrytypes.TelemetryFieldKey{Name: alias, FieldContext: telemetrytypes.FieldContextTrace}
fieldKeys[alias] = []*telemetrytypes.TelemetryFieldKey{key}
}
cb := &aliasConditionBuilder{allowed: allowed, used: make(map[string]struct{})}
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
ConditionBuilder: cb,
FieldKeys: fieldKeys,
Variables: variables,
Builder: sb,
})
if err != nil {
return nil, err
}
if prepared.IsEmpty() {
return nil, nil //nolint:nilnil
}
return &traceHaving{pred: prepared.Expr, used: cb.used}, nil
}
// aliasConditionBuilder renders filter conditions directly against the per-trace
// aliases, recording the ones it touches; a key resolving to no alias is an error.
type aliasConditionBuilder struct {
allowed map[string]struct{}
used map[string]struct{}
}
var _ qbtypes.ConditionBuilder = (*aliasConditionBuilder)(nil)
func (c *aliasConditionBuilder) ConditionFor(
_ context.Context,
_ valuer.UUID,
_, _ uint64,
key *telemetrytypes.TelemetryFieldKey,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
_ qbtypes.ConditionBuilderOptions,
op qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
matching := keys[key.Name]
if len(matching) == 0 {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregate %q cannot be used in a trace-level filter; filterable aggregates: %s",
key.Name, strings.Join(sortedAliases(c.allowed), ", "))
}
alias := matching[0].Name
c.used[alias] = struct{}{}
col := quoteAlias(alias)
var cond string
switch op {
case qbtypes.FilterOperatorEqual:
cond = sb.E(col, value)
case qbtypes.FilterOperatorNotEqual:
cond = sb.NE(col, value)
case qbtypes.FilterOperatorGreaterThan:
cond = sb.G(col, value)
case qbtypes.FilterOperatorGreaterThanOrEq:
cond = sb.GE(col, value)
case qbtypes.FilterOperatorLessThan:
cond = sb.L(col, value)
case qbtypes.FilterOperatorLessThanOrEq:
cond = sb.LE(col, value)
case qbtypes.FilterOperatorIn, qbtypes.FilterOperatorNotIn:
values, ok := value.([]any)
if !ok {
values = []any{value}
}
if op == qbtypes.FilterOperatorIn {
cond = sb.In(col, values...)
} else {
cond = sb.NotIn(col, values...)
}
case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween:
values, ok := value.([]any)
if !ok || len(values) != 2 {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"between on trace-level aggregate %q requires exactly two values", alias)
}
if op == qbtypes.FilterOperatorBetween {
cond = sb.Between(col, values[0], values[1])
} else {
cond = sb.NotBetween(col, values[0], values[1])
}
default:
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"trace-level aggregate %q supports only comparison operators (=, !=, <, <=, >, >=, in, between)", alias)
}
return []string{cond}, nil, nil
}

View File

@@ -31,7 +31,14 @@ type traceQueryStatementBuilder struct {
cb qbtypes.ConditionBuilder
resourceFilterResolver *resourcefilter.ResourceFingerprintResolver[qbtypes.TraceAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fl flagger.Flagger
skipResourceFingerprintEnabled bool
// traceScope, set only on the per-call copy made by BuildTraceScoped, constrains
// queries to spans whose trace_id is in the __trace_scope CTE.
traceScope *qbtypes.Statement
// traceScopeResource is the __resource_filter CTE traceScope's predicate references,
// emitted only when this builder's own resource filter did not already emit it.
traceScopeResource *qbtypes.Statement
}
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
@@ -47,8 +54,8 @@ func NewFactory(
return factory.NewProviderFactory(
factory.MustNewName("traces"),
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, fm, cb, fl)
return NewTraceQueryStatementBuilder(
settings, metadataStore, fm, cb, aggExprRewriter, telemetryStore, fl,
@@ -91,10 +98,46 @@ func NewTraceQueryStatementBuilder(
cb: conditionBuilder,
resourceFilterResolver: resourceFilterResolver,
aggExprRewriter: aggExprRewriter,
fl: flagger,
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
}
}
// BuildTraceScoped is Build constrained to trace_ids selected by traceScope; the
// receiver is copied so the shared builder stays stateless.
func (b *traceQueryStatementBuilder) BuildTraceScoped(
ctx context.Context,
orgID valuer.UUID,
start uint64,
end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
traceScope, traceScopeResource *qbtypes.Statement,
) (*qbtypes.Statement, error) {
scoped := *b
scoped.traceScope = traceScope
scoped.traceScopeResource = traceScopeResource
return scoped.Build(ctx, orgID, start, end, requestType, query, variables)
}
// attachTraceScope adds the trace-scope condition to sb and returns the CTE fragments
// + args to prepend; resourceEmitted reports whether the query already carries the
// __resource_filter CTE, so the scope's copy is emitted only when it does not.
func (b *traceQueryStatementBuilder) attachTraceScope(sb *sqlbuilder.SelectBuilder, resourceEmitted bool) ([]string, [][]any) {
if b.traceScope == nil {
return nil, nil
}
sb.Where("trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)")
var frags []string
var args [][]any
if b.traceScopeResource != nil && !resourceEmitted {
frags = append(frags, fmt.Sprintf("__resource_filter AS (%s)", b.traceScopeResource.Query))
args = append(args, b.traceScopeResource.Args)
}
return append(frags, fmt.Sprintf("__trace_scope AS (%s)", b.traceScope.Query)), append(args, b.traceScope.Args)
}
// Build builds a SQL query for traces based on the given parameters.
func (b *traceQueryStatementBuilder) Build(
ctx context.Context,
@@ -119,7 +162,7 @@ func (b *traceQueryStatementBuilder) Build(
// We modify SelectFields above (injecting default fields), and those default
// fields can carry keys that need evolutions, so fetch keys after that.
keySelectors := getKeySelectors(query)
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.fl, getKeySelectors(query))
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
if err != nil {
@@ -519,6 +562,11 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
cteArgs = append(cteArgs, args)
}
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 {
cteFragments = append(cteFragments, scopeFrags...)
cteArgs = append(cteArgs, scopeArgs...)
}
sb.SelectMore(fmt.Sprintf(
"toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts",
int64(query.StepInterval.Seconds()),
@@ -679,6 +727,13 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
cteArgs = append(cteArgs, args)
}
// skipResourceCTE means this scalar is embedded as a CTE of a time-series query,
// which has already emitted the __trace_scope fragment — add only the condition.
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 && !skipResourceCTE {
cteFragments = append(cteFragments, scopeFrags...)
cteArgs = append(cteArgs, scopeArgs...)
}
allAggChArgs := []any{}
fieldNames := make([]string, 0, len(query.GroupBy))
@@ -795,6 +850,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
OrgID: orgID,
Flagger: b.fl,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,

View File

@@ -8,6 +8,7 @@ import (
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -376,8 +377,8 @@ func TestStatementBuilder(t *testing.T) {
}
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
@@ -677,8 +678,8 @@ func TestStatementBuilderListQuery(t *testing.T) {
}
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
@@ -804,8 +805,8 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = c.keysMap
if mockMetadataStore.KeysMap == nil {
@@ -879,8 +880,8 @@ func TestStatementBuilderGroupByResourceEvolution(t *testing.T) {
}
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
@@ -1046,8 +1047,8 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
}
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
@@ -1684,8 +1685,8 @@ func newSkipResourceFingerprintBuilder(
t.Helper()
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
@@ -1714,8 +1715,8 @@ func TestStatementBuilderGroupByUnseenKey(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
@@ -1756,8 +1757,8 @@ func TestStatementBuilderAggregationUnseenKey(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
@@ -1787,3 +1788,72 @@ func TestStatementBuilderAggregationUnseenKey(t *testing.T) {
assert.Contains(t, q.Query, "attributes_number['error.type']")
assert.Contains(t, q.Query, "attributes_bool['error.type']")
}
// TestStatementBuilderSemconvFamilies builds the same family-member filter
// with the resolve_semconv_families flag on and off. On: the resource filter
// merges both spellings and widens the index hints to any member. Off: the
// query uses only the requested spelling, so users see no change.
func TestStatementBuilderSemconvFamilies(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{
Expression: "deployment.environment.name = 'production'",
},
}
cases := []struct {
name string
flag bool
expected qbtypes.Statement
}{
{
name: "flag on merges both spellings",
flag: true,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Args: []any{"production", "%deployment.environment.name%", "%deployment.environment%", "%deployment.environment.name\":\"production%", "%deployment.environment\":\"production%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
{
name: "flag off keeps the literal spelling",
flag: false,
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
Args: []any{"production", "%deployment.environment.name%", "%deployment.environment.name\":\"production%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): c.flag,
})
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
nil,
fl,
false,
100000,
)
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeScalar, query, nil)
require.NoError(t, err)
require.Equal(t, c.expected.Query, q.Query)
require.Equal(t, c.expected.Args, q.Args)
})
}
}

View File

@@ -212,7 +212,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
return cteName, nil
}
keySelectors := getKeySelectors(*query)
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(ctx, b.orgID, b.stmtBuilder.fl, getKeySelectors(*query))
b.stmtBuilder.logger.DebugContext(ctx, "Key selectors for query", slog.String("query_name", queryName), slog.Any("key_selectors", keySelectors))
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, b.orgID, keySelectors)
if err != nil {
@@ -265,6 +265,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
querybuilder.FilterExprVisitorOpts{
Context: ctx,
OrgID: b.orgID,
Flagger: b.stmtBuilder.fl,
Logger: b.stmtBuilder.logger,
FieldMapper: b.stmtBuilder.fm,
ConditionBuilder: b.stmtBuilder.cb,
@@ -442,7 +443,7 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
}
}
keySelectors := b.getKeySelectors()
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(ctx, b.orgID, b.stmtBuilder.fl, b.getKeySelectors())
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, b.orgID, keySelectors)
if err != nil {
return nil, err

View File

@@ -20,8 +20,8 @@ func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementB
t.Helper()
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)

View File

@@ -25,6 +25,7 @@ type traceOperatorStatementBuilder struct {
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fl flagger.Flagger
}
var _ qbtypes.TraceOperatorStatementBuilder = (*traceOperatorStatementBuilder)(nil)
@@ -41,8 +42,8 @@ func NewOperatorFactory(
return factory.NewProviderFactory(
factory.MustNewName("traceoperator"),
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.TraceOperatorStatementBuilder, error) {
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, fm, cb, fl)
traceStmtBuilder := NewTraceQueryStatementBuilder(
settings, metadataStore, fm, cb, aggExprRewriter, telemetryStore, fl,
@@ -85,6 +86,7 @@ func NewTraceOperatorStatementBuilder(
traceStmtBuilder: traceStmtBuilder,
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
aggExprRewriter: aggExprRewriter,
fl: flagger,
}
}

View File

@@ -21,8 +21,9 @@ import (
func TestTraceTimeRangeOptimization(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
fl := flaggertest.New(t)
fm := tracestelemetryschema.NewFieldMapper(fl)
cb := tracestelemetryschema.NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
@@ -39,7 +40,6 @@ func TestTraceTimeRangeOptimization(t *testing.T) {
Signal: telemetrytypes.SignalTraces,
}}
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
statementBuilder := NewTraceQueryStatementBuilder(

View File

@@ -38,8 +38,11 @@ func (c *conditionBuilder) ConditionFor(
return nil, nil, err
}
// an unknown key simply yields no condition rather than an error.
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
// an unknown key simply yields no condition rather than an error. Metadata
// fields have no family support, so every logical field is single-member
// and flattens losslessly to its physical key.
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(ctx, orgID, nil, key, fieldKeys))
keys := querybuilder.SingleKeys(resolved)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)

View File

@@ -58,6 +58,19 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, tsStart, tsE
return columns, nil
}
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
func (m *fieldMapper) ExistsFor(ctx context.Context, _ valuer.UUID, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
columns, err := m.getColumn(ctx, tsStart, tsEnd, key)
if err != nil {
return "", err
}
pred := fmt.Sprintf("mapContains(%s, '%s')", columns[0].Name, key.Name)
if exists {
return pred, nil
}
return "NOT " + pred, nil
}
func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
columns, err := m.getColumn(ctx, startNs, endNs, key)
if err != nil {

View File

@@ -147,7 +147,10 @@ func (c *conditionBuilder) ConditionFor(
return nil, nil, err
}
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
// Audit fields have no family support, so every logical field is
// single-member and flattens losslessly to its physical key.
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(ctx, orgID, nil, key, fieldKeys))
keys := querybuilder.SingleKeys(resolved)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)

View File

@@ -97,6 +97,19 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
return m.getColumn(ctx, key)
}
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
func (m *fieldMapper) ExistsFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
fieldExpression, err := m.FieldFor(ctx, orgID, tsStart, tsEnd, key)
if err != nil {
return "", err
}
columns, err := m.getColumn(ctx, key)
if err != nil {
return "", err
}
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
}
func (m *fieldMapper) ColumnExpressionFor(
ctx context.Context,
orgID valuer.UUID,

View File

@@ -460,7 +460,7 @@ func (c *conditionBuilder) ConditionFor(
value any,
sb *sqlbuilder.SelectBuilder,
) ([]string, []string, error) {
matches := querybuilder.MatchingFieldKeys(key, fieldKeys)
matches := querybuilder.MatchingLogicalFields(ctx, orgID, nil, key, fieldKeys)
skipResourceFilter := options.SkipResourceFilter
// search() resolves its own (optional) scope; handle it before key resolution.
@@ -468,7 +468,10 @@ func (c *conditionBuilder) ConditionFor(
return c.conditionForSearch(ctx, orgID, key, value, sb)
}
keys, warning := querybuilder.ResolveKeys(key, matches)
// Logs fields have no family support yet, so every logical field is
// single-member and flattens losslessly to its physical key.
resolved, warning := querybuilder.ResolveLogicalFields(key, matches)
keys := querybuilder.SingleKeys(resolved)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)

View File

@@ -279,7 +279,7 @@ func (m *fieldMapper) ColumnExpressionFor(
}
var stmts []string
for _, key := range candidates {
guard, err := m.existsExpressionFor(ctx, orgID, tsStart, tsEnd, key, true)
guard, err := m.ExistsFor(ctx, orgID, tsStart, tsEnd, key, true)
if err != nil {
return "", err
}
@@ -308,7 +308,7 @@ func (m *fieldMapper) ColumnExpressionFor(
if !m.membershipGuarded(ctx, orgID, tsStart, tsEnd, candidates[0]) {
return m.FieldFor(ctx, orgID, tsStart, tsEnd, candidates[0])
}
guard, err := m.existsExpressionFor(ctx, orgID, tsStart, tsEnd, candidates[0], true)
guard, err := m.ExistsFor(ctx, orgID, tsStart, tsEnd, candidates[0], true)
if err != nil {
return "", err
}
@@ -326,7 +326,7 @@ func (m *fieldMapper) ColumnExpressionFor(
var stmts []string
for _, key := range candidates {
guard, err := m.existsExpressionFor(ctx, orgID, tsStart, tsEnd, key, true)
guard, err := m.ExistsFor(ctx, orgID, tsStart, tsEnd, key, true)
if err != nil {
return "", err
}
@@ -569,7 +569,8 @@ func (m *fieldMapper) membershipGuarded(ctx context.Context, orgID valuer.UUID,
return columnType == schema.ColumnTypeEnumMap || columnType == schema.ColumnTypeEnumJSON
}
func (m *fieldMapper) existsExpressionFor(
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
func (m *fieldMapper) ExistsFor(
ctx context.Context,
orgID valuer.UUID,
tsStart, tsEnd uint64,

View File

@@ -162,7 +162,9 @@ func (c *conditionBuilder) ConditionFor(
return nil, nil, err
}
keys := querybuilder.MatchingFieldKeys(key, fieldKeys)
// Metric labels have no family support, so every logical field is
// single-member and flattens losslessly to its physical key.
keys := querybuilder.SingleKeys(querybuilder.MatchingLogicalFields(ctx, orgID, nil, key, fieldKeys))
var warnings []string
if len(keys) == 0 {
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {

View File

@@ -96,6 +96,18 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, tsStart, tsE
return m.getColumn(ctx, tsStart, tsEnd, key)
}
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
// Intrinsic fields always exist; labels are checked for key membership.
func (m *fieldMapper) ExistsFor(_ context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error) {
if slices.Contains(IntrinsicFields, key.Name) {
return "true", nil
}
if exists {
return fmt.Sprintf("has(JSONExtractKeys(labels), '%s')", key.Name), nil
}
return fmt.Sprintf("not has(JSONExtractKeys(labels), '%s')", key.Name), nil
}
func (m *fieldMapper) ColumnExpressionFor(
ctx context.Context,
orgID valuer.UUID,

View File

@@ -9,6 +9,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -19,12 +20,15 @@ import (
type conditionBuilder struct {
fm qbtypes.FieldMapper
// fl evaluates the resolve_semconv_families flag during resolution.
// A nil flagger keeps resolution literal.
fl flagger.Flagger
}
var _ qbtypes.ConditionBuilder = (*conditionBuilder)(nil)
func NewConditionBuilder(fm qbtypes.FieldMapper) *conditionBuilder {
return &conditionBuilder{fm: fm}
func NewConditionBuilder(fm qbtypes.FieldMapper, fl flagger.Flagger) *conditionBuilder {
return &conditionBuilder{fm: fm, fl: fl}
}
func (c *conditionBuilder) conditionFor(
@@ -32,7 +36,7 @@ func (c *conditionBuilder) conditionFor(
orgID valuer.UUID,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
logical *telemetrytypes.LogicalField,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
@@ -42,13 +46,13 @@ func (c *conditionBuilder) conditionFor(
value = querybuilder.FormatValueForContains(value)
}
fieldExpression, err := c.fm.FieldFor(ctx, orgID, startNs, endNs, key)
fieldExpression, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, c.fm, logical)
if err != nil {
return "", err
}
// TODO(srikanthccv): maybe extend this to every possible attribute
if key.Name == "duration_nano" || key.Name == "durationNano" { // QoL improvement
if logical.Name == "duration_nano" || logical.Name == "durationNano" { // QoL improvement
switch v := value.(type) {
case string:
if duration, err := time.ParseDuration(v); err == nil {
@@ -65,7 +69,9 @@ func (c *conditionBuilder) conditionFor(
}
}
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
// Coercion switches only on the data type, which every member shares, so
// the first member stands in for the field.
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(logical.Single(), value, fieldExpression, operator)
// regular operators
switch operator {
@@ -135,7 +141,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, logical, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
@@ -150,7 +156,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, logical, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
@@ -162,11 +168,7 @@ func (c *conditionBuilder) conditionFor(
// in the query builder, `exists` and `not exists` are used for
// key membership checks, so depending on the column type, the condition changes
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
columns, err := c.fm.ColumnFor(ctx, orgID, startNs, endNs, key)
if err != nil {
return "", err
}
pred, err := querybuilder.ExistsExpression(columns, key, startNs, endNs, fieldExpression, operator == qbtypes.FilterOperatorExists)
pred, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, c.fm, logical, operator == qbtypes.FilterOperatorExists)
if err != nil {
return "", err
}
@@ -218,10 +220,10 @@ func (c *conditionBuilder) ConditionFor(
return nil, nil, err
}
matches := querybuilder.MatchingFieldKeys(key, fieldKeys)
matches := querybuilder.MatchingLogicalFields(ctx, orgID, c.fl, key, fieldKeys)
skipResourceFilter := options.SkipResourceFilter
keys, warning := querybuilder.ResolveKeys(key, matches)
logicalFields, warning := querybuilder.ResolveLogicalFields(key, matches)
var warnings []string
if warning != "" {
warnings = append(warnings, warning)
@@ -229,10 +231,10 @@ func (c *conditionBuilder) ConditionFor(
// A bare key that names a real column filters on the column too — first. When metadata
// only knows the name under other contexts, prepend the column and keep metadata matches
// only where their type is consistent with it (a corrupt entry can't degrade the column).
if key.FieldContext == telemetrytypes.FieldContextUnspecified && len(keys) > 0 {
if key.FieldContext == telemetrytypes.FieldContextUnspecified && len(logicalFields) > 0 {
hasColumn := false
for _, k := range keys {
if k.FieldContext == telemetrytypes.FieldContextSpan {
for _, logical := range logicalFields {
if logical.FieldContext == telemetrytypes.FieldContextSpan {
hasColumn = true
break
}
@@ -240,49 +242,49 @@ func (c *conditionBuilder) ConditionFor(
if !hasColumn {
probe := telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextSpan, key.FieldDataType)
if cols, colErr := c.fm.ColumnFor(ctx, orgID, startNs, endNs, probe); colErr == nil && len(cols) > 0 {
combined := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys)+1)
combined = append(combined, probe)
for _, k := range keys {
if columnMatchesDataType(cols[0], k.FieldDataType) {
combined = append(combined, k)
combined := make([]*telemetrytypes.LogicalField, 0, len(logicalFields)+1)
combined = append(combined, telemetrytypes.SingleLogicalField(key.Name, probe))
for _, logical := range logicalFields {
if columnMatchesDataType(cols[0], logical.FieldDataType) {
combined = append(combined, logical)
}
}
keys = combined
logicalFields = combined
}
}
}
synthesized := false
if len(keys) == 0 {
if len(logicalFields) == 0 {
// Not in metadata. CandidateKeys resolves it: fold contexts (span/trace) get the
// metadata map so it can honor a real column, correct to a stripped-name metadata
// match, or synthesize; strict contexts pass nil and keep their synthesize path.
keys = c.fm.CandidateKeys(ctx, orgID, key, value, candidateLookupKeys(key, fieldKeys))
if len(keys) == 0 {
logicalFields = querybuilder.WrapAsLogicalFields(key.Name, c.fm.CandidateKeys(ctx, orgID, key, value, candidateLookupKeys(key, fieldKeys)))
if len(logicalFields) == 0 {
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
}
synthesized = true
warnings = append(warnings, querybuilder.NewKeyNotFoundWarning(key.Name))
}
// When a resource sub-query already covers the term, drop resource keys from the main
// When a resource sub-query already covers the term, drop resource fields from the main
// query. Synthesized keys are exempt: the sub-query skips keys absent from metadata.
if skipResourceFilter && !synthesized {
filtered := make([]*telemetrytypes.TelemetryFieldKey, 0, len(keys))
for _, k := range keys {
if k.FieldContext != telemetrytypes.FieldContextResource {
filtered = append(filtered, k)
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
for _, logical := range logicalFields {
if logical.FieldContext != telemetrytypes.FieldContextResource {
filtered = append(filtered, logical)
}
}
if len(filtered) == 0 {
return nil, warnings, nil
}
keys = filtered
logicalFields = filtered
}
conds := make([]string, 0, len(keys))
for _, k := range keys {
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
conds := make([]string, 0, len(logicalFields))
for _, logical := range logicalFields {
cond, err := c.conditionForLogicalField(ctx, orgID, startNs, endNs, logical, operator, value, sb)
if err != nil {
return nil, nil, err
}
@@ -291,28 +293,28 @@ func (c *conditionBuilder) ConditionFor(
return conds, warnings, nil
}
func (c *conditionBuilder) conditionForKey(
func (c *conditionBuilder) conditionForLogicalField(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
logical *telemetrytypes.LogicalField,
operator qbtypes.FilterOperator,
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
if c.isSpanScopeField(key.Name) {
return c.buildSpanScopeCondition(key, operator, value, startNs)
if c.isSpanScopeField(logical.Name) {
return c.buildSpanScopeCondition(logical.Single(), operator, value, startNs)
}
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, operator, value, sb)
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, logical, operator, value, sb)
if err != nil {
return "", err
}
if operator.AddDefaultExistsFilter() {
// skip adding exists filter for intrinsic fields
field, _ := c.fm.FieldFor(ctx, orgID, startNs, endNs, key)
field, _ := c.fm.FieldFor(ctx, orgID, startNs, endNs, logical.Single())
if slices.Contains(maps.Keys(IntrinsicFields), field) ||
slices.Contains(maps.Keys(IntrinsicFieldsDeprecated), field) ||
slices.Contains(maps.Keys(CalculatedFields), field) ||
@@ -320,7 +322,7 @@ func (c *conditionBuilder) conditionForKey(
return condition, nil
}
existsCondition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorExists, nil, sb)
existsCondition, err := c.conditionFor(ctx, orgID, startNs, endNs, logical, qbtypes.FilterOperatorExists, nil, sb)
if err != nil {
return "", err
}

View File

@@ -2,6 +2,7 @@ package tracestelemetryschema
import (
"context"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"testing"
"time"
@@ -288,8 +289,8 @@ func TestConditionFor(t *testing.T) {
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
fm := NewFieldMapper(flaggertest.New(t))
conditionBuilder := NewConditionBuilder(fm, flaggertest.New(t))
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
@@ -375,8 +376,8 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
},
}
fm := NewFieldMapper()
conditionBuilder := NewConditionBuilder(fm)
fm := NewFieldMapper(flaggertest.New(t))
conditionBuilder := NewConditionBuilder(fm, flaggertest.New(t))
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
@@ -395,8 +396,8 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
// user input and queries anyway, emitting a warning instead of failing.
func TestConditionForSynthesizedKeys(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
// no metadata matches -> the builder must synthesize from user input
var noMatches map[string][]*telemetrytypes.TelemetryFieldKey

View File

@@ -0,0 +1,243 @@
package tracestelemetryschema
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/require"
)
// familyFlagOn returns a flagger with resolve_semconv_families on.
func familyFlagOn(t *testing.T) flagger.Flagger {
return flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
}
// The tests below run against the deployment.environment(.name) family as
// trace resource attributes with the canonical evolution timeline, inside the
// JSON-column window. The single-member value expression at this range is
//
// multiIf(resource.`<name>` IS NOT NULL, resource.`<name>`::String,
// mapContains(resources_string, '<name>'), resources_string['<name>'], NULL)
//
// and the family expressions below compose it per member.
func TestConditionForFamilyMergesMembersCurrentFirst(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startNs, endNs := uint64(1747947419000000000), uint64(1747983448000000000)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
}
fl := familyFlagOn(t)
cb := NewConditionBuilder(NewFieldMapper(fl), fl)
// The requested spelling is the old name; precedence must still be
// current-first.
sb := sqlbuilder.NewSelectBuilder()
conds, warnings, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb)
require.NoError(t, err)
require.Empty(t, warnings, "a family is one logical field, never ambiguous with itself")
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE (COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), '') = ? AND (multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL))", sql)
require.Equal(t, []any{"production"}, args)
}
func TestConditionForFamilyNegativeKeepsKeylessRows(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startNs, endNs := uint64(1747947419000000000), uint64(1747983448000000000)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
}
fl := familyFlagOn(t)
cb := NewConditionBuilder(NewFieldMapper(fl), fl)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotEqual, "production", sb)
require.NoError(t, err)
require.Len(t, conds, 1)
// The trailing '' makes rows without any member read '' (single-key map
// semantics), so `!=` keeps including them; no exists filter is added.
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), '') <> ?", sql)
require.Equal(t, []any{"production"}, args)
}
func TestConditionForFamilyExists(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startNs, endNs := uint64(1747947419000000000), uint64(1747983448000000000)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
}
fl := familyFlagOn(t)
cb := NewConditionBuilder(NewFieldMapper(fl), fl)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorNotExists, nil, sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE NOT (multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL)", sql)
}
// With the flag off, both spellings can be in the metadata map and the
// condition still uses only the requested key. Users see no change until the
// flag is on.
func TestConditionForFamilyOffByDefault(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startNs, endNs := uint64(1747947419000000000), uint64(1747983448000000000)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
}
fl := flaggertest.New(t)
cb := NewConditionBuilder(NewFieldMapper(fl), fl)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE (multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) = ? AND multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL)", sql)
require.Equal(t, []any{"production"}, args)
}
// With only one member in metadata the condition is the exact single-key
// shape: composition only appears when metadata proves a second member.
func TestConditionForSingleMemberIsUnchanged(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startNs, endNs := uint64(1747947419000000000), uint64(1747983448000000000)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
}
delete(fieldKeys, "deployment.environment")
fl := familyFlagOn(t)
cb := NewConditionBuilder(NewFieldMapper(fl), fl)
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(context.Background(), valuer.UUID{}, startNs, endNs,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"},
fieldKeys, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "production", sb)
require.NoError(t, err)
require.Len(t, conds, 1)
sb.Where(conds...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
require.Equal(t, "WHERE (multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) = ? AND multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL)", sql)
require.Equal(t, []any{"production"}, args)
}
func TestColumnExpressionForFamilyGroupBy(t *testing.T) {
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
startNs, endNs := uint64(1747947419000000000), uint64(1747983448000000000)
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
Evolutions: MockEvolutionData(releaseTime),
}},
}
fm := NewFieldMapper(familyFlagOn(t))
expr, err := fm.ColumnExpressionFor(context.Background(), valuer.UUID{}, startNs, endNs,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, telemetrytypes.FieldDataTypeString, fieldKeys)
require.NoError(t, err)
require.Equal(t, "multiIf((multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL), COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), ''), NULL)", expr)
}

View File

@@ -7,6 +7,7 @@ import (
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -159,12 +160,16 @@ var (
}
)
type fieldMapper struct{}
type fieldMapper struct {
// fl evaluates the resolve_semconv_families flag during resolution.
// A nil flagger keeps resolution literal.
fl flagger.Flagger
}
var _ qbtypes.FieldMapper = (*fieldMapper)(nil)
func NewFieldMapper() *fieldMapper {
return &fieldMapper{}
func NewFieldMapper(fl flagger.Flagger) *fieldMapper {
return &fieldMapper{fl: fl}
}
func (m *fieldMapper) getColumn(
@@ -336,6 +341,68 @@ func (m *fieldMapper) resolveColumnExprs(
return exprs, existExprs, columns, nil
}
// logicalForResolvedColumn upgrades a directly-resolvable key (the FieldFor
// probe succeeded) to its family when the metadata map proves membership;
// otherwise the key stays a single-member logical field.
func (m *fieldMapper) logicalForResolvedColumn(ctx context.Context, orgID valuer.UUID, field *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) *telemetrytypes.LogicalField {
for _, logical := range querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys) {
if logical.IsFamily() &&
logical.FieldContext == field.FieldContext &&
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || logical.FieldDataType == field.FieldDataType) {
return logical
}
}
return telemetrytypes.SingleLogicalField(field.Name, field)
}
// upgradeToFamilies swaps single-member candidates for their family when the
// metadata map proves membership. Candidate order and every non-family
// candidate stay exactly as the legacy flow produced them; sibling candidates
// of an already-emitted family are dropped rather than duplicated.
func (m *fieldMapper) upgradeToFamilies(ctx context.Context, orgID valuer.UUID, field *telemetrytypes.TelemetryFieldKey, candidates []*telemetrytypes.LogicalField, keys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
var families []*telemetrytypes.LogicalField
for _, logical := range querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys) {
if logical.IsFamily() {
families = append(families, logical)
}
}
if len(families) == 0 {
return candidates
}
out := make([]*telemetrytypes.LogicalField, 0, len(candidates))
emitted := make(map[*telemetrytypes.LogicalField]bool)
for _, candidate := range candidates {
var family *telemetrytypes.LogicalField
for _, fam := range families {
if fam.FieldContext != candidate.FieldContext || fam.FieldDataType != candidate.FieldDataType {
continue
}
memberOfFamily := candidate.Single().Name == field.Name
for _, member := range fam.Members {
if member.Name == candidate.Single().Name {
memberOfFamily = true
break
}
}
if memberOfFamily {
family = fam
break
}
}
if family == nil {
out = append(out, candidate)
continue
}
if emitted[family] {
continue
}
emitted[family] = true
out = append(out, family)
}
return out
}
// ColumnExpressionFor returns the bare (unaliased) SQL expression for the field, resolving
// unknown keys via CandidateKeys and wrapping guardable columns with exists-guard multiIfs
// so an absent key yields NULL.
@@ -348,18 +415,23 @@ func (m *fieldMapper) ColumnExpressionFor(
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (string, error) {
// Resolve the candidate column(s).
var candidates []*telemetrytypes.TelemetryFieldKey
// Resolve the candidate logical field(s).
var candidates []*telemetrytypes.LogicalField
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
case err == nil:
candidates = []*telemetrytypes.TelemetryFieldKey{field}
// A directly-resolvable key upgrades to its family when the metadata
// map proves membership; otherwise it stays single-member.
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
case errors.Is(err, qbtypes.ErrColumnNotFound):
// column (when the bare name is one) plus metadata matches, else synthesized
// type-variant keys.
candidates = m.CandidateKeys(ctx, orgID, field, nil, keys)
if len(candidates) == 0 {
// The legacy candidate flow, unchanged: column (when the bare name is
// one) plus metadata matches, else synthesized type-variant keys. The
// family step below only swaps candidates for their family; it never
// changes candidate order or non-family behavior.
raw := m.CandidateKeys(ctx, orgID, field, nil, keys)
if len(raw) == 0 {
return "", errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name).WithSuggestions(errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys))...)
}
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
default:
return "", err
}
@@ -373,21 +445,21 @@ func (m *fieldMapper) ColumnExpressionFor(
dummyValue = 0.0
}
stmts := make([]string, 0, len(candidates)*2)
for _, key := range candidates {
value, err := m.FieldFor(ctx, orgID, startNs, endNs, key)
for _, logical := range candidates {
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
if err != nil {
return "", err
}
guard, err := m.existsExpressionFor(ctx, orgID, startNs, endNs, key, true)
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
if err != nil {
return "", err
}
coerced := value
// a time column keeps its native type; coercing it would yield seconds
if temporal, err := m.columnIsTemporal(ctx, startNs, endNs, key); err != nil {
if temporal, err := m.logicalIsTemporal(ctx, startNs, endNs, logical); err != nil {
return "", err
} else if !temporal {
coerced, _ = querybuilder.DataTypeCollisionHandledFieldName(key, dummyValue, value, qbtypes.FilterOperatorUnknown)
coerced, _ = querybuilder.DataTypeCollisionHandledFieldName(logical.Single(), dummyValue, value, qbtypes.FilterOperatorUnknown)
}
stmts = append(stmts, guard, coerced)
}
@@ -395,13 +467,14 @@ func (m *fieldMapper) ColumnExpressionFor(
}
if len(candidates) == 1 {
value, err := m.FieldFor(ctx, orgID, startNs, endNs, candidates[0])
logical := candidates[0]
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
if err != nil {
return "", err
}
exprs, existExprs, _, _ := m.resolveColumnExprs(ctx, startNs, endNs, candidates[0])
if len(exprs) == 1 && len(existExprs) == 1 {
guard, err := m.existsExpressionFor(ctx, orgID, startNs, endNs, candidates[0], true)
exprs, existExprs, _, _ := m.resolveColumnExprs(ctx, startNs, endNs, logical.Single())
if !logical.IsFamily() && len(exprs) == 1 && len(existExprs) == 1 {
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
if err != nil {
return "", err
}
@@ -413,12 +486,12 @@ func (m *fieldMapper) ColumnExpressionFor(
// Multiple candidates (collision / synth): multiIf picks the first that exists,
// stringified so branches share a type.
args := make([]string, 0, len(candidates))
for _, key := range candidates {
value, err := m.FieldFor(ctx, orgID, startNs, endNs, key)
for _, logical := range candidates {
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
if err != nil {
return "", err
}
guard, err := m.existsExpressionFor(ctx, orgID, startNs, endNs, key, true)
guard, err := querybuilder.LogicalExistsExpr(ctx, orgID, startNs, endNs, m, logical, true)
if err != nil {
return "", err
}
@@ -427,6 +500,15 @@ func (m *fieldMapper) ColumnExpressionFor(
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
}
// logicalIsTemporal reports whether the logical field resolves to a single time
// column. A family is attribute-backed and never temporal.
func (m *fieldMapper) logicalIsTemporal(ctx context.Context, startNs, endNs uint64, logical *telemetrytypes.LogicalField) (bool, error) {
if logical.IsFamily() {
return false, nil
}
return m.columnIsTemporal(ctx, startNs, endNs, logical.Single())
}
// columnIsTemporal reports whether key resolves to a single time column, after evolution
// selection. Multiple columns mean an attribute-map union, which is never temporal.
func (m *fieldMapper) columnIsTemporal(ctx context.Context, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (bool, error) {
@@ -522,7 +604,8 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
return nil
}
func (m *fieldMapper) existsExpressionFor(
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
func (m *fieldMapper) ExistsFor(
ctx context.Context,
orgID valuer.UUID,
tsStart, tsEnd uint64,

View File

@@ -2,6 +2,7 @@ package tracestelemetryschema
import (
"context"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"testing"
"time"
@@ -107,7 +108,7 @@ func TestGetFieldKeyName(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
fm := NewFieldMapper(flaggertest.New(t))
result, err := fm.FieldFor(ctx, valuer.UUID{}, uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()), uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano()), &tc.key)
if tc.expectedError != nil {
@@ -195,7 +196,7 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
fm := NewFieldMapper(flaggertest.New(t))
result, err := fm.FieldFor(ctx, valuer.UUID{}, tc.tsStart, tc.tsEnd, &tc.key)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
@@ -255,7 +256,7 @@ func TestColumnExpressionForTemporalColumn(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper()
fm := NewFieldMapper(flaggertest.New(t))
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &tc.key, tc.requiredDataType, nil)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
@@ -283,7 +284,7 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
},
}
fm := NewFieldMapper()
fm := NewFieldMapper(flaggertest.New(t))
t.Run("bare timestamp resolves to the intrinsic column alone", func(t *testing.T) {
bare := telemetrytypes.TelemetryFieldKey{Name: "timestamp"}

View File

@@ -2,6 +2,7 @@ package tracestelemetryschema
import (
"context"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -14,8 +15,8 @@ import (
func TestSpanScopeFilterExpression(t *testing.T) {
// Test that span scope fields work in filter expressions
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
tests := []struct {
name string
@@ -128,8 +129,8 @@ func TestSpanScopeWithResourceFilter(t *testing.T) {
// For now, just verify the expression parses correctly
// In a real implementation, we'd need to check that the resource filter
// is properly skipped when span scope fields are present
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
// Prepare field keys for the test
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey)

View File

@@ -113,6 +113,21 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
},
// both spellings of an enabled semantic-convention family
"deployment.environment.name": {
{
Name: "deployment.environment.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"deployment.environment": {
{
Name: "deployment.environment",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
for _, keys := range keysMap {
for _, key := range keys {

View File

@@ -18,7 +18,14 @@ var (
ErrUnsupportedOperator = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported operator")
)
// FieldMapper maps the telemetry field key to the table field name.
// FieldMapper is a signal's storage dialect: given a resolved physical key, it
// renders how this signal's tables express that key — its value (FieldFor),
// its presence (ExistsFor), and its backing columns (ColumnFor) — honoring
// materialization and column-generation choices carried on the key.
//
// ColumnExpressionFor and CandidateKeys additionally carry per-signal
// resolution behavior (unknown-name fallback, candidate ordering); they
// predate the resolution layer and are slated to move behind it.
type FieldMapper interface {
// FieldFor returns the field name for the given key.
FieldFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey) (string, error)
@@ -34,6 +41,11 @@ type FieldMapper interface {
// the name (or `{context}.{name}`) first, else synthesized type-variant keys for sources
// that support it, else nil (caller errors). value is the filter operand, nil otherwise.
CandidateKeys(ctx context.Context, orgID valuer.UUID, field *telemetrytypes.TelemetryFieldKey, value any, keys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey
// ExistsFor returns the existence predicate for a single physical key (negated when
// exists is false), self-contained and arg-free so it can guard column expressions.
// It is the per-member primitive querybuilder.LogicalExistsExpr and the numeric branch
// of querybuilder.LogicalValueExpr compose family expressions from.
ExistsFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, key *telemetrytypes.TelemetryFieldKey, exists bool) (string, error)
}
// ConditionBuilder builds the conditions for a filter term. The builder owns key resolution:

View File

@@ -1,5 +1,16 @@
from datetime import datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.querier import (
Aggregation,
BuilderQuery,
OrderBy,
RequestType,
TelemetryFieldKey,
get_scalar_table_data,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
@@ -32,10 +43,10 @@ def ai_trace(
*,
now: datetime,
service: str,
user: str,
in_tokens: int | None,
out_tokens: int,
cost: float,
user: str = "user",
cost: float = 0.1,
model: str = "gpt-4o-mini",
environment: str = "production",
) -> list[Traces]:
@@ -74,6 +85,28 @@ def ai_trace(
]
def tool_only_trace(*, now: datetime, service: str) -> list[Traces]:
"""Root + one tool span: passes the gen_ai gate but has NO LLM span."""
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
resources = {"service.name": service}
return [
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=2),
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=0.5),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="execute_tool",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"gen_ai.tool.name": "get_weather", "gen_ai.tool.type": "function"},
),
]
def ai_trace_mixed_spans(*, now: datetime, service: str, user: str) -> list[Traces]:
"""Root + LLM + tool + agent spans; only the LLM span carries gen_ai.request.model."""
trace_id = TraceIdGenerator.trace_id()
@@ -116,3 +149,48 @@ def ai_trace_mixed_spans(*, now: datetime, service: str, user: str) -> list[Trac
),
child("agent.step", TracesKind.SPAN_KIND_INTERNAL, {"gen_ai.agent.name": "chat-agent"}, 2),
]
def ai_aggregation_query(
service: str,
expression: str,
*,
filter_extra: str = "",
group_by: list[TelemetryFieldKey] | None = None,
alias: str | None = None,
having: str | None = None,
order: list[OrderBy] | None = None,
limit: int | None = None,
step_interval: int | None = None,
) -> dict:
filter_expression = f"service.name = '{service}'"
if filter_extra:
filter_expression += f" AND {filter_extra}"
return BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression=filter_expression,
aggregations=[Aggregation(expression=expression, alias=alias)],
group_by=group_by,
having_expression=having,
order=order,
limit=limit,
step_interval=step_interval,
).to_dict()
def scalar_value(signoz: types.SigNoz, token: str, start_ms: int, end_ms: int, service: str, expression: str, filter_extra: str = "") -> float:
"""The single cell of a one-aggregation, ungrouped scalar query."""
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[ai_aggregation_query(service, expression, filter_extra=filter_extra)],
request_type=RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK, f"{expression}: {response.text}"
data = get_scalar_table_data(response.json())
assert len(data) == 1, f"{expression}: expected one row, got {data}"
return float(data[0][-1])

View File

@@ -24,12 +24,12 @@ def create_service_account(signoz: types.SigNoz, token: str, name: str, role: st
role_id = find_role_by_name(signoz, token, role)
role_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": service_account_id, "roleId": role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert role_resp.status_code == HTTPStatus.NO_CONTENT, role_resp.text
assert role_resp.status_code == HTTPStatus.CREATED, role_resp.text
return service_account_id
@@ -85,12 +85,12 @@ def create_service_account_with_roles(signoz: types.SigNoz, token: str, name: st
for role in roles:
role_id = find_role_by_name(signoz, token, role)
role_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": service_account_id, "roleId": role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert role_resp.status_code == HTTPStatus.NO_CONTENT, role_resp.text
assert role_resp.status_code == HTTPStatus.CREATED, role_resp.text
return service_account_id

View File

@@ -129,4 +129,4 @@ def test_delete_user(
assert response.status_code == HTTPStatus.OK
data = response.json()["data"]
assert data["status"] == "deleted"
assert len(data["userRoles"]) == 1
assert len(data["userRoles"]) == 0

View File

@@ -72,8 +72,8 @@ def test_ai_list_having_aggregate_filter(
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Span + aggregate condition in one filter box splits into WHERE + HAVING; bare
and `trace.` spellings behave identically; an output-only aggregate is rejected."""
"""One filter box splits into WHERE + HAVING; bare and `trace.` spellings behave
identically; an output-only aggregate is rejected."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-having"
@@ -322,9 +322,8 @@ def test_ai_list_nested_group_span_or_and_aggregate(
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""service.name = X AND (has_error = true OR gen_ai.request.model = 'gpt-4o') AND
total_tokens > 100: the nested OR group must not flatten, span predicates go to
WHERE, the aggregate to HAVING."""
"""A nested (span OR span) group ANDed with an aggregate must not flatten: span
predicates go to WHERE, the aggregate to HAVING."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-nested"

View File

@@ -0,0 +1,502 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
Aggregation,
BuilderQuery,
OrderBy,
RequestType,
TelemetryFieldKey,
get_all_series,
get_scalar_columns,
get_scalar_table_data,
get_series_values,
make_query_request,
)
from fixtures.querierai import ai_aggregation_query, ai_trace, query_window, scalar_value, tool_only_trace
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_ai_scalar_trace_level_aggregations(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Scalars over per-trace values, and the bare-key span domain through the same request type."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-scalar"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
def value(expression: str) -> float:
return scalar_value(signoz, token, start_ms, end_ms, service, expression)
assert value("avg(trace.output_tokens)") == pytest.approx(200)
assert value("count(trace.trace_id)") == 2
assert value("max(trace.total_tokens)") == pytest.approx(330)
assert value("p50(trace.output_tokens)") == pytest.approx(200) # AggreFuncMap -> quantile(0.50)
# arithmetic inside one function and between functions
assert value("avg(trace.output_tokens + trace.input_tokens)") == pytest.approx(220)
assert value("sum(trace.output_tokens)/count(trace.trace_id)") == pytest.approx(200)
assert value("count()") == 2 # the two LLM spans; roots are not gen_ai
assert value("sum(gen_ai.usage.output_tokens)") == pytest.approx(400)
# multiple trace-level aggregations in one query -> one column per aggregation
multi = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression=f"service.name = '{service}'",
aggregations=[Aggregation(expression="avg(trace.output_tokens)"), Aggregation(expression="count(trace.trace_id)")],
)
resp = make_query_request(signoz, token, start_ms, end_ms, [multi.to_dict()], request_type=RequestType.SCALAR)
assert resp.status_code == HTTPStatus.OK, resp.text
data = get_scalar_table_data(resp.json())
assert len(data) == 1 and [float(v) for v in data[0]] == [pytest.approx(200), 2], data
def test_ai_scalar_trace_level_filter_qualifies_traces(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A trace-level condition qualifies whole traces before aggregation, on both domains."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-qualify"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
for expression in (
"sum(trace.output_tokens)", # native trace-domain path
"sum(gen_ai.usage.output_tokens)", # delegated span-domain path (__trace_scope)
):
got = scalar_value(signoz, token, start_ms, end_ms, service, expression, filter_extra="trace.output_tokens > 100")
assert got == pytest.approx(300), expression
# the qualification also constrains delegated (span-domain) time series
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[
ai_aggregation_query(
service,
"sum(gen_ai.usage.output_tokens)",
filter_extra="trace.output_tokens > 100",
step_interval=60,
)
],
request_type=RequestType.TIME_SERIES,
)
assert resp.status_code == HTTPStatus.OK, resp.text
assert [v["value"] for v in get_series_values(resp.json(), "A")] == [pytest.approx(300)]
def test_ai_scalar_group_by_model(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Trace-level aggregation grouped by a span attribute."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-groupby"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini"))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[ai_aggregation_query(service, "avg(trace.output_tokens)", group_by=[TelemetryFieldKey(name="gen_ai.request.model")])],
request_type=RequestType.SCALAR,
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = get_scalar_table_data(resp.json())
by_model = {row[0]: float(row[-1]) for row in data}
assert by_model == {"gpt-4o": pytest.approx(200), "gpt-4o-mini": pytest.approx(50)}, data
def test_ai_scalar_group_by_intrinsic_span_column(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Grouping by an intrinsic must not alias the group column to the span column it reads
(`toString(name) AS name` is a cyclic alias ClickHouse rejects)."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-groupby-intrinsic"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=10, out_tokens=300) + tool_only_trace(now=now, service=service))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[
ai_aggregation_query(
service,
"count(trace.trace_id)",
group_by=[TelemetryFieldKey(name="name")],
order=[OrderBy(key=TelemetryFieldKey(name="name"), direction="asc")],
)
],
request_type=RequestType.SCALAR,
)
assert resp.status_code == HTTPStatus.OK, resp.text
columns = get_scalar_columns(resp.json())
assert columns[0]["name"] == "name", columns
data = get_scalar_table_data(resp.json())
# the root spans are gated out, so each trace groups under its gen_ai span name
assert [(row[0], int(row[-1])) for row in data] == [("chat gpt-4o-mini", 2), ("execute_tool", 1)], data
def test_ai_timeseries_trace_level_aggregation(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-ts"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
# all spans fall in one step bucket
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[ai_aggregation_query(service, "avg(trace.output_tokens)", step_interval=60)],
request_type=RequestType.TIME_SERIES,
)
assert resp.status_code == HTTPStatus.OK, resp.text
assert [v["value"] for v in get_series_values(resp.json(), "A")] == [pytest.approx(200)]
def test_ai_timeseries_top_n_groups(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A grouped, limited time series ranks groups on whole-window per-trace values in
the requested order."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-topn"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=100, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini"))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
def top_series(order: list[OrderBy] | None) -> dict:
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[
ai_aggregation_query(
service,
"sum(trace.output_tokens)",
group_by=[TelemetryFieldKey(name="gen_ai.request.model")],
alias="total_out",
order=order,
limit=1,
step_interval=60,
)
],
request_type=RequestType.TIME_SERIES,
)
assert resp.status_code == HTTPStatus.OK, resp.text
series = get_all_series(resp.json(), "A")
assert len(series) == 1, f"limit=1 must keep exactly one group, got {len(series)} series"
return series[0]
top = top_series(None) # default ranking: first aggregation desc
assert top["labels"][0]["value"] == "gpt-4o", top["labels"]
assert [v["value"] for v in top["values"]] == [pytest.approx(400)]
bottom = top_series([OrderBy(key=TelemetryFieldKey(name="total_out"), direction="asc")])
assert bottom["labels"][0]["value"] == "gpt-4o-mini", bottom["labels"]
assert [v["value"] for v in bottom["values"]] == [pytest.approx(50)]
def test_ai_scalar_group_order_limit(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Scalar limit is a plain top-N over the grouped rows."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-scalar-limit"
insert_traces(
ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o")
+ ai_trace(now=now, service=service, in_tokens=10, out_tokens=100, model="gpt-4o")
+ ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini")
+ ai_trace(now=now, service=service, in_tokens=10, out_tokens=10, model="gpt-4")
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[
ai_aggregation_query(
service,
"sum(trace.output_tokens)",
group_by=[TelemetryFieldKey(name="gen_ai.request.model")],
alias="total_out",
order=[OrderBy(key=TelemetryFieldKey(name="total_out"), direction="desc")],
limit=2,
)
],
request_type=RequestType.SCALAR,
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = get_scalar_table_data(resp.json())
assert [(row[0], float(row[-1])) for row in data] == [("gpt-4o", pytest.approx(400)), ("gpt-4o-mini", pytest.approx(50))], data
def test_ai_timeseries_span_time_bucketing(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Per-trace values are clipped per (bucket, trace), so a trace spanning two buckets
contributes each call's tokens to its own bucket, not the total to both."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-buckets"
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
resources = {"service.name": service}
def llm(offset_s: float, out_tokens: int) -> Traces:
return Traces(
timestamp=now - timedelta(seconds=offset_s),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="chat",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"gen_ai.request.model": "gpt-4o-mini", "gen_ai.usage.output_tokens": out_tokens},
)
root = Traces(
timestamp=now - timedelta(seconds=130),
duration=timedelta(seconds=130),
trace_id=trace_id,
span_id=root_id,
parent_span_id="",
name="POST /api/chat",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"http.request.method": "POST"},
)
# two LLM calls two minutes apart
insert_traces([root, llm(124, 100), llm(4, 300)])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[ai_aggregation_query(service, "avg(trace.output_tokens)", step_interval=60)],
request_type=RequestType.TIME_SERIES,
)
assert resp.status_code == HTTPStatus.OK, resp.text
series = get_all_series(resp.json(), "A")
assert len(series) == 1, series
assert sorted(v["value"] for v in series[0]["values"]) == [pytest.approx(100), pytest.approx(300)], series
def test_ai_scalar_variables_in_trace_level_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""Variables resolve inside trace-level conditions with span-filter semantics."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-vars"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + ai_trace(now=now, service=service, in_tokens=30, out_tokens=300))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
query = ai_aggregation_query(service, "sum(trace.output_tokens)", filter_extra="trace.output_tokens > $threshold")
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[query],
request_type=RequestType.SCALAR,
variables={"threshold": {"type": "text", "value": 100}},
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = get_scalar_table_data(resp.json())
assert len(data) == 1 and float(data[0][-1]) == pytest.approx(300), data
# an unresolvable $var is a 400 today via aggregate validation
resp = make_query_request(signoz, token, start_ms, end_ms, [query], request_type=RequestType.SCALAR)
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.text
# quotes in the message are JSON-escaped, so match the halves separately
assert "$threshold" in resp.text and "cannot be used in a trace-level filter" in resp.text, resp.text
# a dynamic variable resolved to __all__ drops the condition (both traces count)
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[query],
request_type=RequestType.SCALAR,
variables={"threshold": {"type": "dynamic", "value": "__all__"}},
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = get_scalar_table_data(resp.json())
assert len(data) == 1 and float(data[0][-1]) == pytest.approx(400), data
def test_ai_scalar_tool_only_trace_null_semantics(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A tool-only trace (in the gate, no LLM span) follows plain SQL NULL semantics."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-toolonly"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100) + tool_only_trace(now=now, service=service))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
def value(expression: str, filter_extra: str = "") -> float:
return scalar_value(signoz, token, start_ms, end_ms, service, expression, filter_extra)
assert value("count(trace.trace_id)") == 2, "tool-only trace is an AI trace and must be counted"
assert value("avg(trace.output_tokens)") == pytest.approx(100), "NULL tokens are skipped by avg"
assert value("avg(trace.tool_call_count)") == pytest.approx(0.5), "tool-only trace feeds tool aggregates (1 and 0 calls)"
assert value("count()") == 2, "span-level count sees the LLM and the tool span"
# filtering on LLM activity is explicit, not implicit
assert value("count(trace.trace_id)", filter_extra="trace.llm_call_count > 0") == 1
def test_ai_scalar_having_on_aggregation(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""The outer having filters aggregation results per group (by alias)."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-having"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=300, model="gpt-4o") + ai_trace(now=now, service=service, in_tokens=10, out_tokens=50, model="gpt-4o-mini"))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
resp = make_query_request(
signoz,
token,
start_ms,
end_ms,
[
ai_aggregation_query(
service,
"avg(trace.output_tokens)",
group_by=[TelemetryFieldKey(name="gen_ai.request.model")],
alias="avg_out",
having="avg_out > 100",
)
],
request_type=RequestType.SCALAR,
)
assert resp.status_code == HTTPStatus.OK, resp.text
data = get_scalar_table_data(resp.json())
assert len(data) == 1 and data[0][0] == "gpt-4o", data
def test_ai_aggregation_rejections(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-agg-reject"
insert_traces(ai_trace(now=now, service=service, in_tokens=10, out_tokens=100))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
def expect_bad_request(query: dict, message: str) -> None:
resp = make_query_request(signoz, token, start_ms, end_ms, [query], request_type=RequestType.SCALAR)
assert resp.status_code == HTTPStatus.BAD_REQUEST, resp.text
assert message in resp.text, resp.text
# span-level and trace-level aggregations cannot be mixed in one query
mixed = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression=f"service.name = '{service}'",
aggregations=[Aggregation(expression="avg(trace.output_tokens)"), Aggregation(expression="count()")],
)
expect_bad_request(mixed.to_dict(), "cannot be mixed")
expect_bad_request(
ai_aggregation_query(service, "avg(trace.output_tokens)", group_by=[TelemetryFieldKey(name="trace.llm_call_count")]),
"grouping by trace-level aggregate",
)
# a bare per-trace column would emit one row per trace instead of one aggregated row
expect_bad_request(ai_aggregation_query(service, "trace.output_tokens"), "must be inside an aggregation function")
# the rate interval divides the whole expression, so it may not carry a second aggregation
expect_bad_request(ai_aggregation_query(service, "rate(trace.trace_id) + avg(trace.output_tokens)"), "combines a rate with another aggregation")
# order-by is stopped earlier, by request validation
expect_bad_request(
ai_aggregation_query(service, "avg(trace.output_tokens)", order=[OrderBy(key=TelemetryFieldKey(name="trace.total_tokens"), direction="desc")]),
"invalid order by key",
)

View File

@@ -267,6 +267,40 @@ def test_delete_role_with_assignee_guarded(
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
def test_delete_role_after_deleting_assigned_user(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = create_role(admin_token, "crud-deleted-assignee-role", [transaction_group("read", "metaresource", "dashboard", ["*"])])
user_id = create_active_user(
signoz,
admin_token,
email="crud+deleted-assignee@integration.test",
role="signoz-viewer",
password=CRUD_ASSIGNEE_USER_PASSWORD,
name="crud-deleted-assignee-user",
)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": role_id},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.NO_CONTENT, f"delete role after deleting its only assignee: {resp.text}"
def test_delete_removes_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument

View File

@@ -44,7 +44,7 @@ def test_assign_role_to_service_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""POST /{id}/roles adds a role alongside existing ones."""
"""POST /service_account_roles adds a role alongside existing ones."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# create service account with viewer role
@@ -53,12 +53,13 @@ def test_assign_role_to_service_account(
# assign editor role (additive — viewer stays)
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": editor_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": service_account_id, "roleId": editor_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
assert assign_resp.status_code == HTTPStatus.CREATED, assign_resp.text
assert assign_resp.json()["data"]["id"]
# verify both viewer and editor roles are present
roles_resp = requests.get(
@@ -75,12 +76,12 @@ def test_assign_role_to_service_account(
# assign admin role — all three should be present
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": admin_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": service_account_id, "roleId": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
assert assign_resp.status_code == HTTPStatus.CREATED, assign_resp.text
roles_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
@@ -100,7 +101,7 @@ def test_assign_role_idempotent(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""POST same role twice succeeds (replace with same role is idempotent)."""
"""POST same role twice returns the same assignment (idempotent)."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
service_account_id = create_service_account(signoz, token, "sa-role-idempotent", role="signoz-viewer")
@@ -108,12 +109,12 @@ def test_assign_role_idempotent(
# assign the same role again
resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": viewer_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": service_account_id, "roleId": viewer_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.NO_CONTENT, resp.text
assert resp.status_code == HTTPStatus.CREATED, resp.text
# verify only one instance of the role
roles_resp = requests.get(
@@ -147,12 +148,12 @@ def test_assign_role_expands_access(
# assign admin role (additive — viewer stays)
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": admin_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": service_account_id, "roleId": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
assert assign_resp.status_code == HTTPStatus.CREATED, assign_resp.text
# SA should now have admin access
resp = requests.get(
@@ -180,24 +181,32 @@ def test_remove_role_from_service_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""DELETE /{id}/roles/{rid} revokes one role while keeping others."""
"""DELETE /service_account_roles/{id} revokes one role while keeping others."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
service_account_id = create_service_account(signoz, token, "sa-remove-role", role="signoz-editor")
# add admin role (now has editor + admin)
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
assign_resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles"),
json={"id": admin_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": service_account_id, "roleId": admin_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert assign_resp.status_code == HTTPStatus.NO_CONTENT, assign_resp.text
assert assign_resp.status_code == HTTPStatus.CREATED, assign_resp.text
# find the editor assignment entry id from the service account detail
detail_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert detail_resp.status_code == HTTPStatus.OK, detail_resp.text
editor_entry_id = next(entry["id"] for entry in detail_resp.json()["data"]["serviceAccountRoles"] if entry["role"]["name"] == "signoz-editor")
# remove editor role
editor_role_id = find_role_by_name(signoz, token, "signoz-editor")
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles/{editor_role_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/service_account_roles/{editor_entry_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -232,10 +241,18 @@ def test_remove_role_verify_access_lost(
)
assert resp.status_code == HTTPStatus.OK, resp.text
# find the admin assignment entry id from the service account detail
detail_resp = requests.get(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert detail_resp.status_code == HTTPStatus.OK, detail_resp.text
admin_entry_id = next(entry["id"] for entry in detail_resp.json()["data"]["serviceAccountRoles"] if entry["role"]["name"] == "signoz-admin")
# remove admin role
admin_role_id = find_role_by_name(signoz, token, "signoz-admin")
del_resp = requests.delete(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{service_account_id}/roles/{admin_role_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/service_account_roles/{admin_entry_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)

View File

@@ -131,8 +131,8 @@ def test_write_forbidden_without_grant(
assert resp.status_code == HTTPStatus.FORBIDDEN, f"update SA: expected 403, got {resp.status_code}: {resp.text}"
resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{target_id}/roles"),
json={"id": viewer_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": target_id, "roleId": viewer_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -220,17 +220,18 @@ def test_attach_detach_dual_scoped(
# Both SA-attach (target id) and role-attach (editor) present -> allowed.
resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{target_id}/roles"),
json={"id": editor_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": target_id, "roleId": editor_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.NO_CONTENT, f"assign editor to target: {resp.text}"
assert resp.status_code == HTTPStatus.CREATED, f"assign editor to target: {resp.text}"
editor_entry_id = resp.json()["data"]["id"]
# SA-attach not held for the other SA id -> forbidden.
resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{other_id}/roles"),
json={"id": editor_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": other_id, "roleId": editor_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -238,8 +239,8 @@ def test_attach_detach_dual_scoped(
# role-attach not held for viewer -> forbidden even on the target SA.
resp = requests.post(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{target_id}/roles"),
json={"id": viewer_role_id},
signoz.self.host_configs["8080"].get("/api/v1/service_account_roles"),
json={"serviceAccountId": target_id, "roleId": viewer_role_id},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
@@ -247,7 +248,7 @@ def test_attach_detach_dual_scoped(
# Both SA-detach (target id) and role-detach (editor) present -> remove allowed.
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{target_id}/roles/{editor_role_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/service_account_roles/{editor_entry_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)