Compare commits

..

70 Commits

Author SHA1 Message Date
Nikhil Soni
3942c8dede test(traces-qb): correct the scope declared-path doc comment 2026-08-25 20:06:52 +05:30
Nikhil Soni
3a9e6e78ad refactor(traces-qb): keep the resolved-column comment signal-agnostic
More than one home under one name is the general case; scope is one instance.
Also fold the two rewritten cases back onto single lines, as the rest of the
table is.

Assisted-by: Claude Opus 5
2026-08-25 19:58:55 +05:30
Nikhil Soni
bf27773965 test(traces-qb): expect scope names to union every home they resolve to
A `name`/`version` scope attribute used to be unreachable, so these cases
asserted that `scope.name` meant the declared path alone and that a bare
`name` never left the span column. Both now resolve, and a scope attribute
colliding with a name behaves like any other same-named attribute.

Renamed the three cases: their ids stated the old contract.

Assisted-by: Claude Opus 5
2026-08-25 19:45:26 +05:30
Nikhil Soni
4e06ba0230 refactor(traces-qb): inline the resolved-column lookup
The helper wrapped two lines behind a name that described the old
single-field behaviour. At the call site the fallback reads directly.

Assisted-by: Claude Opus 5
2026-08-25 19:21:06 +05:30
Nikhil Soni
cf5d9dafe1 Revert "test(traces-qb): cover semconv families for context-carrying select keys"
The extension was justified by resolving select through metadata before the
probe. That reordering is gone: a family match now behaves the same as it
always did, since logicalForResolvedColumn returned the family and returns a
list whose only element is that family. The extra key shapes pin nothing this
change alters.

Assisted-by: Claude Opus 5
2026-08-25 19:10:54 +05:30
Nikhil Soni
f665a1e0b9 test(traces-qb): pin the scope-prefixed name under an explicit context
A caller-supplied context means `scope.` is part of the name, not a prefix to
strip, so the key addresses a scope attribute literally named
`scope.testing.env`. Nothing covered the asymmetry with the normalized
spelling, which resolves to `testing.env`.

Assisted-by: Claude Opus 5
2026-08-25 18:45:12 +05:30
Nikhil Soni
73d2c5d09c refactor(traces-qb): keep every metadata match for a resolved column
Resolving select through metadata before the probe worked, but it reordered
resolution for every context-carrying key to fix one thing: a key that resolves
to a column is not necessarily one field.

Say that where it belongs instead. logicalForResolvedColumn already asks
metadata; it just threw away all but a family and fell back to the key itself.
Returning every match it finds fixes the same ambiguity without moving
resolution around: ColumnExpressionFor keeps its probe-first shape and differs
from main by a single line.

The scope column resolves for both a declared path and a same-named scope
attribute, so `scope.name` with such an attribute present now yields both homes
rather than one. Output is identical to the reordered version on every case
that motivated it -- declared-only, attribute-shadowed, materialized attribute,
and an undeclared scope attribute.

Assisted-by: Claude Opus 5
2026-08-25 17:16:41 +05:30
Nikhil Soni
3425776ca6 test(traces-qb): cover semconv families for context-carrying select keys
TestColumnExpressionForFamilyGroupBy only exercised a bare key, which resolves
through the candidate path. A key carrying a context now resolves through
metadata instead, and nothing covered that the family is still reached there.

Run the existing assertion over all three key shapes. The expression is
identical for each: MatchingLogicalFields returns the grouped family directly,
which is what logicalForResolvedColumn was selecting from the same call.

Assisted-by: Claude Opus 5
2026-08-25 16:17:57 +05:30
Nikhil Soni
39b2fc5cef refactor(traces-qb): restore CandidateKeys metadata match to main's form
The context-filtered bare-name match and the appended `{context}.{name}`
spelling existed to let a scope key find its declared path once getColumn
declined scope `name`/`version`. Resolving select through metadata first moved
that job to MatchingLogicalFields, so the extra matching is dead weight: the
suite is green with main's two straight lookups restored.

Keep the scope synthesize case -- it is not redundant. A scope attribute
metadata does not know still reaches CandidateKeys from the filter path, which
passes nil keys for strict contexts, and without the case it resolves to
nothing and the filter fails with "key not found". No test covered that, so
add one.

Assisted-by: Claude Opus 5
2026-08-25 12:44:58 +05:30
Nikhil Soni
bae9a15f2a fix(traces-qb): ask metadata before the column probe when selecting
ColumnExpressionFor resolved a key by probing FieldFor first, while the filter
path asks metadata first. The probe only answers whether a key resolves to a
column, and was standing in for whether it names one field. For a JSON column
that resolves for either of two homes -- a declared scope path or a same-named
scope attribute -- it reported a single resolved field for an ambiguous name,
so getColumn had to decline scope `name` and `version` to force the key back
onto the candidate path. That made a scope attribute so named unselectable:
it failed with "field not found".

Ask metadata first for a key that carries a context, the way the filter path
does. A bare key is left exactly as it was -- it cannot resolve through the
probe at all, since getColumn needs a context, so it already reaches
CandidateKeys, which consults metadata itself.

The filter path is untouched: its rule that one home wins an ambiguous name is
a deliberate choice, and selecting still coalesces the homes instead.

Assisted-by: Claude Opus 5
2026-08-24 12:40:01 +05:30
Nikhil Soni
3871692b3d fix: resolve declared scope paths and dual-context keys in CandidateKeys
The metadata match in CandidateKeys context-filtered every match, dropping a
bare key's cross-context metadata (a name present in both attribute and
resource resolved to attributes only) and, for a specified context, discarding
the literal {context}.{name} spelling (e.g. an attribute named span.test was
unreachable under span context). Filter only the bare-name matches by context,
take the compound-name match as-is, and keep the unfiltered fallback.

The scope synth branch no longer special-cases the declared scope paths:
getTracesKeys already surfaces scope.name / scope.version as intrinsic keys, so
they resolve through the metadata match; synth now only handles undeclared scope
attributes.

Assisted-by: Claude Opus 4.8
2026-08-21 20:41:07 +05:30
Nikhil Soni
5a7a36b83f fix: fix context check for matched keys 2026-08-21 19:20:12 +05:30
Nikhil Soni
c5264c617f refactor: check for context in key match 2026-08-21 18:55:58 +05:30
Nikhil Soni
2a88baae4f fix: prefer context prefixed keys over bare names if available 2026-08-21 15:03:11 +05:30
Nikhil Soni
e3f9628c5a chore: remove unnecessary redirection with small methods 2026-08-21 12:48:47 +05:30
Nikhil Soni
545ba1d64d fix: add context.name in the key selectors for select clause 2026-08-20 21:18:51 +05:30
Nikhil Soni
f5b6ec6f28 fix: restrict direct query on attributes with same name as declared path 2026-08-20 20:08:01 +05:30
Nikhil Soni
0d7350b2f1 test(queriertraces): cover scope-prefixed attribute resolution
Add a scope attribute whose own name carries a scope. prefix (scope.prefixed)
and a filter case for it. This exercises the targeted scope.<name> metadata
selector: without it the metadata fetch never surfaces a scope.-prefixed key
name and the filter resolves to nothing.

Assisted-by: Claude Opus 4.8
2026-08-19 22:41:58 +05:30
Nikhil Soni
79bfcc987b chore: regenerate openapi specs for scope field context
Add scope to the FieldContext enum in the generated OpenAPI spec and frontend
client (follows enabling FieldContextScope in the enum). Map the new scope value
to undefined in the alert-history suggestion Record so the exhaustive mapping
stays complete.

Assisted-by: Claude Opus 4.8
2026-08-19 19:28:52 +05:30
Nikhil Soni
8bad55bcb3 test(queriertraces): inline the query window in the scope-filter test
Replace the undefined _query_window(now) call with the inline start/end-ms
computation used by the other tests in the suite, fixing the ruff F821 lint
failure. Per the suite's pytest conventions, test modules avoid `_`-prefixed
helpers and inline such logic.

Assisted-by: Claude Opus 4.8
2026-08-19 19:25:27 +05:30
Nikhil Soni
cb05e09a85 refactor(traces): extract lookupIntrinsicOrCalculatedField as a package helper
Move the intrinsic/calculated field lookup out of adjustTraceKey into a
package-level function, consistent with the other helpers in this file.

Assisted-by: Claude Opus 4.8
2026-08-19 19:17:28 +05:30
Nikhil Soni
3bc493175e refactor(traces): resolve intrinsics by context-qualified name in adjustTraceKey
Look up the intrinsic/calculated field by the key's <context>.<name> first, then
its bare name. The qualified lookup lets a scope-context key match the declared
scope.name / scope.version intrinsic instead of the span field that shares its
short name (e.g. {name, scope} is the scope's name, not the span name column),
replacing the scope-specific guard with a generic name resolution.

Assisted-by: Claude Opus 4.8
2026-08-19 19:11:49 +05:30
Nikhil Soni
9f0bbf2209 refactor(traces): treat scope.name/version as intrinsics in adjustTraceKey
Resolve scope keys through the normal intrinsic path instead of skipping
adjustTraceKey. scope.name / scope.version are matched by their full name as
scope-context intrinsics (context preserved, no override), while a scope-context
key is guarded from binding to a span-context intrinsic that shares only its
short name (e.g. {name, scope} must not become the span name column). The
context-blind span<->attribute remapping of legacy fields is untouched.

Assisted-by: Claude Opus 4.8
2026-08-19 19:05:23 +05:30
Nikhil Soni
4c98b1e5b3 refactor(traces): synthesize scope attribute only under scope context
Drop the scope-attribute candidate from the bare (unspecified) and span/trace
synthesize paths in CandidateKeys; a scope attribute is synthesized only for an
explicit scope context, matching main's per-context synthesis. Unqualified keys
absent from metadata no longer probe scope.attributes. Revert the affected
condition/statement builder test expectations accordingly.

Assisted-by: Claude Opus 4.8
2026-08-19 19:00:51 +05:30
Nikhil Soni
1cb343dbd8 refactor(traces): simplify scope resolution to match main's field-mapper structure
Resolve scope keys through a dedicated ColumnExpressionFor branch
(MatchingLogicalFields + ResolveLogicalFields, CandidateKeys fallback) alongside
main's FieldFor switch for non-scope, dropping the resolveReferencedField
refactor. This restores main's non-scope behavior (accurateCastOrNull coercion,
single-candidate attribute resolution) that the refactor had unintentionally
changed, and keeps scope short-name/declared-path/attribute-union handling.

adjustTraceKey uses a single scope early-return; FieldContextScope is enabled in
the field-context enum. query_to_keys still emits a targeted scope.<name>
selector so scope.-prefixed attributes resolve reliably (test added).

Assisted-by: Claude Opus 4.8
2026-08-19 18:37:12 +05:30
Nikhil Soni
82b2331116 Merge remote-tracking branch 'origin/main' into ns/scope 2026-08-19 17:15:15 +05:30
Nikhil Soni
cb2e8f5d35 fix(traces): resolve short scope name to the declared scope path
A scope-context key whose short name matches a declared scope path (e.g.
{name, scope}) must resolve to that declared path (scope.name), not the span
name column and not an undeclared scope attribute. The adjustTraceKey guard
keeps such a key in scope context, and the field mapper's no-metadata synth now
maps the short name to the declared scope path. With a same-named scope
attribute also in metadata, the two union as before.

Assisted-by: Claude Opus 4.8
2026-08-19 11:52:22 +05:30
Aditya Singh
1aa6346a4c fix(logs): render log details v2 only on the logs explorer route (#12616)
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
<!--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
Render Log details on log explorer only. disabled on other places for
now.

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

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

<!--Please delete paragraphs that you did not use before submitting.-->
2026-08-19 05:21:07 +00:00
Nikhil Soni
d8d4c477c2 fix(traces): resolve scope-context intrinsic collision in adjustTraceKey
A scope-context key that shares its name with a span intrinsic (e.g. the
scope's name addressed as {name, scope}) was resolved against the span 'name'
column when no metadata matched. Guard the intrinsic/calculated lookup in
adjustTraceKey so a scope-context key never binds to a non-scope intrinsic;
the intentionally context-blind span<->attribute remapping of legacy fields is
left untouched. This supersedes the AdjustKey change in collision.go, which is
reverted to keep the shared query builder generic.

Assisted-by: Claude Opus 4.8
2026-08-18 20:08:16 +05:30
Nikhil Soni
54c31332bb test: cover scope select union of declared path and same-named attribute
Add ColumnExpressionFor cases for scope names that collide with a declared
scope path: a short name under scope context binds to the declared path, unions
a same-named scope attribute when present, and the full scope.<x> spelling under
explicit scope context addresses the declared path alone. Add the matching
statement-builder list-query case asserting the full union SELECT.

Assisted-by: Claude Opus 4.8
2026-08-18 19:49:05 +05:30
Nikhil Soni
050f5405e9 refactor: simplify the key resolution 2026-08-18 18:53:05 +05:30
Nikhil Soni
dd97cc3bf0 Merge remote-tracking branch 'origin/main' into ns/scope
# Conflicts:
#	pkg/telemetryschema/tracestelemetryschema/condition_builder.go
#	pkg/telemetryschema/tracestelemetryschema/field_mapper.go
#	pkg/telemetryschema/tracestelemetryschema/test_data.go
2026-08-18 17:31:54 +05:30
Nikhil Soni
e3d39386f9 chore: remove evolution logic from scope exists 2026-08-18 17:09:03 +05:30
Nikhil Soni
3205551b63 refactor: move scope exist expr to signal specific module 2026-08-18 14:55:38 +05:30
Nikhil Soni
7aafd63d11 fix: revert normz. fix due to large blast radius 2026-08-18 12:17:31 +05:30
Nikhil Soni
933c009093 fix: normalization consistant and wrong context override removed 2026-08-18 12:16:04 +05:30
Nikhil Soni
cff401ad79 fix: mark instrinsic only when given context doesn't exist 2026-08-17 19:35:54 +05:30
Nikhil Soni
abefa35fde fix: handle exists expr after recent refactor 2026-08-11 22:17:14 +05:30
Nikhil Soni
5c5a4a7a3f Merge remote-tracking branch 'origin/main' into ns/scope 2026-08-11 17:34:30 +05:30
Nikhil Soni
883e9492d6 Merge remote-tracking branch 'origin/main' into ns/scope 2026-08-10 15:01:20 +05:30
Nikhil Soni
ab3b88966e Merge remote-tracking branch 'origin/main' into ns/scope
# Conflicts:
#	pkg/telemetryschema/tracestelemetryschema/field_mapper.go
#	tests/conftest.py
#	tests/integration/tests/querier/04_traces.py
2026-08-05 16:44:41 +05:30
Nikhil Soni
08ebc37109 fix: handle fixed scope.name and scope.version fields to work without scope 2026-07-02 19:04:41 +05:30
Nikhil Soni
aa5a1c5e62 chore: update collector version 2026-07-02 14:46:07 +05:30
Nikhil Soni
7b34a47ac5 chore: run formatting on tests 2026-07-02 14:41:33 +05:30
Nikhil Soni
b4b2d7bb66 test: add test to show cross context matching 2026-06-24 18:34:38 +05:30
Nikhil Soni
e16416475b refactor: drop unused fields 2026-06-24 18:07:47 +05:30
Nikhil Soni
0ea7c1ae6e test: add more cases for scope name 2026-06-24 18:05:50 +05:30
Nikhil Soni
a023c8ed4a test: add integration test for scope fields 2026-06-24 15:25:17 +05:30
Nikhil Soni
a73ae62cd1 Merge remote-tracking branch 'origin/main' into ns/scope 2026-06-24 13:02:18 +05:30
Nikhil Soni
ec6fb58052 chore: add more tests 2026-06-24 12:37:42 +05:30
Nikhil Soni
d3d13eb7ff fix: remove handling of normalized properties for scope
Otherwise it will be impossible to query if scope attribute also
exists with same name - name and version
2026-05-21 11:19:22 +05:30
Nikhil Soni
782de2b210 fix: use correct error type for internal issues 2026-05-20 15:32:26 +05:30
Nikhil Soni
d3c38693f3 fix: allow 'scope.' prefix for keys with other context 2026-05-20 15:30:25 +05:30
Nikhil Soni
8791df3697 fix: avoid removing context prefix to support attr with prefix 2026-05-19 19:14:23 +05:30
Nikhil Soni
eb719c3d0d fix: use key selector with context prefix 2026-05-19 18:56:00 +05:30
Nikhil Soni
f10435c210 Merge remote-tracking branch 'origin' into ns/scope 2026-05-19 13:55:36 +05:30
Nikhil Soni
f3f1e9cb59 chore: add tests for denormalized field name as well 2026-05-19 13:55:25 +05:30
Nikhil Soni
d0370ce3ef fix: handle fields with included context for scope (select clause) 2026-05-14 17:02:56 +05:30
Nikhil Soni
d169761e65 Merge remote-tracking branch 'origin/main' into ns/scope 2026-05-14 11:50:33 +05:30
Nikhil Soni
87864ef5d4 chore: remove duplicates from .gitignore 2026-05-11 15:45:32 +05:30
Nikhil Soni
2e0bc8998e chore: use name as key name for scope instead of scope.name 2026-05-11 15:40:45 +05:30
Nikhil Soni
7e1f4aa50d Merge remote-tracking branch 'origin/main' into ns/scope 2026-05-11 14:27:19 +05:30
Nikhil Soni
35da39247c Merge branch 'main' into ns/scope 2026-05-07 17:41:11 +05:30
Nikhil Soni
ceccc47a34 fix: fix test for case without resource filter 2026-05-07 16:04:03 +05:30
Nikhil Soni
23da5e22ec Merge branch 'main' into ns/scope 2026-05-07 13:27:34 +05:30
Nikhil Soni
4c1b479149 chore: add tests for scope fields 2026-04-28 20:27:10 +05:30
Nikhil Soni
f72204a8b2 refactor: simplify field mapper for scope 2026-04-28 20:26:37 +05:30
Nikhil Soni
deb3f385fa chore: remove underscore version of scope fields 2026-04-23 10:26:55 +05:30
Nikhil Soni
77ce5f86b1 fix: use scope as json field instead with name and version 2026-04-23 01:15:02 +05:30
Nikhil Soni
ff211de441 feat: add support for scope fields in traces 2026-04-14 10:45:08 +05:30
33 changed files with 1114 additions and 1354 deletions

View File

@@ -8807,6 +8807,7 @@ components:
- span
- trace
- resource
- scope
- attribute
- body
- ""

View File

@@ -3492,6 +3492,7 @@ export enum TelemetrytypesFieldContextDTO {
span = 'span',
trace = 'trace',
resource = 'resource',
scope = 'scope',
attribute = 'attribute',
body = 'body',
'' = '',

View File

@@ -18,10 +18,9 @@ jest.mock('periscope/components/DataViewer', () => ({
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
}));
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
isLogDetailsV2: true,
// Force v2 for these tests regardless of route.
jest.mock('../useIsLogDetailsV2', () => ({
useIsLogDetailsV2: (): boolean => true,
}));
const mockLog: ILog = {

View File

@@ -1,6 +1,3 @@
// temporary flag to be removed with old log details code.
export const isLogDetailsV2 = true;
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',
JSON: 'JSON',

View File

@@ -51,11 +51,12 @@ import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
import './LogDetails.styles.scss';
@@ -92,6 +93,8 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const isLogDetailsV2 = useIsLogDetailsV2();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {

View File

@@ -0,0 +1,9 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
// v2 is rolled out only on the logs explorer route for now; every other surface
// (dashboards, infra monitoring, etc.) keeps the v1 log details view.
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return pathname === ROUTES.LOGS_EXPLORER;
}

View File

@@ -10,6 +10,7 @@ const fieldContextToSuggestionMap: Record<
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.trace]: undefined,
[TelemetrytypesFieldContextDTO.scope]: undefined,
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,

View File

@@ -13,7 +13,7 @@ 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 { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
import { DataViewer } from 'periscope/components/DataViewer';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -69,6 +69,8 @@ function Overview({
isListViewPanel,
});
const isLogDetailsV2 = useIsLogDetailsV2();
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);

View File

@@ -56,6 +56,17 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
FieldDataType: key.FieldDataType,
})
}
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
// https://github.com/SigNoz/signoz/issues/11374
if key.FieldContext == telemetrytypes.FieldContextScope {
keys = append(keys, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Signal: key.Signal,
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
FieldDataType: key.FieldDataType,
})
}
}
}

View File

@@ -72,6 +72,44 @@ func TestQueryToKeys(t *testing.T) {
},
},
},
{
query: `scope.version = '1.0.0'`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
{
Name: "scope.version",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},
},
{
// A scope attribute whose own name carries a `scope.` prefix. `scope.prefixed`
// normalizes to {prefixed, scope}; the second selector re-adds the prefix so the
// metadata fetch can target the attribute's exact key `scope.prefixed` rather than
// relying on the broad `%prefixed%` match.
query: `scope.prefixed = 'x'`,
expectedKeys: []telemetrytypes.FieldKeySelector{
{
Name: "prefixed",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
{
Name: "scope.prefixed",
Signal: telemetrytypes.SignalUnspecified,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
},
},
},
}
for _, testCase := range testCases {

View File

@@ -462,8 +462,8 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSON_VALUE(body, '$.\"status\"') = ? AND LOWER(body) LIKE LOWER(?)) AND (JSON_EXISTS(body, '$.\"status\"') AND LOWER(body) LIKE LOWER(?))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"success", "%success%", "%\"status\"%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (JSON_VALUE(body, '$.\"status\"') = ? AND JSON_EXISTS(body, '$.\"status\"')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"success", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("status")},
},
expectedErr: nil,
@@ -481,8 +481,8 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (((JSON_VALUE(body, '$.\"user_names\"[*]') = ? AND LOWER(body) LIKE LOWER(?))) AND (JSON_EXISTS(body, '$.\"user_names\"[*]') AND LOWER(body) LIKE LOWER(?))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "%john\\_doe%", "%\"user\\_names\"%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSON_VALUE(body, '$.\"user_names\"[*]') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("user_names[*]")},
},
expectedErr: nil,
@@ -498,8 +498,8 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$.\"user_names\"') = ? AND JSONType(body, 'user_names') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "john_doe", "%\"user\\_names\"%", "%john\\_doe%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$.\"user_names\"') = ? AND JSONType(body, 'user_names') NOT IN ('Array', 'Object', 'Null')), false)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("user_names[*]")},
},
expectedErr: nil,
@@ -1011,8 +1011,8 @@ func TestStmtBuilderBodyField(t *testing.T) {
},
enableUseJSONBody: false,
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE (body = ? AND LOWER(body) = LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"", "", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE body = ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},

View File

@@ -163,31 +163,15 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
}
for idx := range query.GroupBy {
groupBy := query.GroupBy[idx]
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: groupBy.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: groupBy.FieldContext,
FieldDataType: groupBy.FieldDataType,
})
keySelectors = append(keySelectors, keySelectorsForField(query.GroupBy[idx].TelemetryFieldKey)...)
}
for idx := range query.SelectFields {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: query.SelectFields[idx].Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: query.SelectFields[idx].FieldContext,
FieldDataType: query.SelectFields[idx].FieldDataType,
})
keySelectors = append(keySelectors, keySelectorsForField(query.SelectFields[idx])...)
}
for idx := range query.Order {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: query.Order[idx].Key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: query.Order[idx].Key.FieldContext,
FieldDataType: query.Order[idx].Key.FieldDataType,
})
keySelectors = append(keySelectors, keySelectorsForField(query.Order[idx].Key.TelemetryFieldKey)...)
}
for idx := range keySelectors {
@@ -198,6 +182,26 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
return keySelectors
}
func keySelectorsForField(key telemetrytypes.TelemetryFieldKey) []*telemetrytypes.FieldKeySelector {
selectors := []*telemetrytypes.FieldKeySelector{
{
Name: key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
},
}
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
Name: key.FieldContext.StringValue() + "." + key.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextUnspecified,
FieldDataType: key.FieldDataType,
})
}
return selectors
}
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
// definitions to the keys map. We do this during statement building, not at
// metadata fetch time, because:
@@ -269,20 +273,14 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
For example: trace_id (intrinsic), response_status_code (calculated).
*/
// Resolve against the context-qualified name first, then the bare name since that can be instrinsic field e.g. scope.name.
var isIntrinsicOrCalculatedField bool
var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey
if _, ok := tracestelemetryschema.IntrinsicFields[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFields[key.Name]
} else if _, ok := tracestelemetryschema.CalculatedFields[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFields[key.Name]
} else if _, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]
} else if _, ok := tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.FieldContext.StringValue() + "." + key.Name)
}
if !isIntrinsicOrCalculatedField {
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.Name)
}
if isIntrinsicOrCalculatedField {
@@ -294,6 +292,24 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
return actions
}
// lookupIntrinsicOrCalculatedField returns the intrinsic or calculated field registered under
// name, across the current and deprecated tables.
func lookupIntrinsicOrCalculatedField(name string) (telemetrytypes.TelemetryFieldKey, bool) {
if f, ok := tracestelemetryschema.IntrinsicFields[name]; ok {
return f, true
}
if f, ok := tracestelemetryschema.CalculatedFields[name]; ok {
return f, true
}
if f, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[name]; ok {
return f, true
}
if f, ok := tracestelemetryschema.CalculatedFieldsDeprecated[name]; ok {
return f, true
}
return telemetrytypes.TelemetryFieldKey{}, false
}
// buildListQuery builds a query for list panel type.
func (b *traceQueryStatementBuilder) buildListQuery(
ctx context.Context,

View File

@@ -374,6 +374,94 @@ func TestStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "scope.name filter and group by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.name = 'opentelemetry-io'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_scope.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_scope.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_scope.name`",
Args: []any{"opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
{
name: "scope.version filter with scope.name group by",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_scope.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_scope.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_scope.name`",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
expectedErr: nil,
},
{
name: "scope.version filter only (no scope field in group by)",
requestType: qbtypes.RequestTypeTimeSeries,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "count()",
},
},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
},
},
}
fl := flaggertest.New(t)
@@ -800,6 +888,143 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
},
expectedErr: nil,
},
{
name: "List query with scope filter only (no scope in select or group by)",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.version": {
{
Name: "scope.version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{
Expression: "scope.version = '1.0.0'",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, trace_state AS `__SELECT_KEY_3_trace_state`, parent_span_id AS `__SELECT_KEY_4_parent_span_id`, flags AS `__SELECT_KEY_5_flags`, name AS `__SELECT_KEY_6_name`, kind AS `__SELECT_KEY_7_kind`, kind_string AS `__SELECT_KEY_8_kind_string`, duration_nano AS `__SELECT_KEY_9_duration_nano`, status_code AS `__SELECT_KEY_10_status_code`, status_message AS `__SELECT_KEY_11_status_message`, status_code_string AS `__SELECT_KEY_12_status_code_string`, events AS `__SELECT_KEY_13_events`, links AS `__SELECT_KEY_14_links`, response_status_code AS `__SELECT_KEY_15_response_status_code`, external_http_url AS `__SELECT_KEY_16_external_http_url`, http_url AS `__SELECT_KEY_17_http_url`, external_http_method AS `__SELECT_KEY_18_external_http_method`, http_method AS `__SELECT_KEY_19_http_method`, http_host AS `__SELECT_KEY_20_http_host`, db_name AS `__SELECT_KEY_21_db_name`, db_operation AS `__SELECT_KEY_22_db_operation`, has_error AS `__SELECT_KEY_23_has_error`, is_remote AS `__SELECT_KEY_24_is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
{
// Regression test: scope.version in selectFields with no metadata (isColumn=true filters it out)
// must still produce scope.version::String, not scope.attributes.version::String
name: "scope.version in selectFields only, no metadata (intrinsic field fallback)",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "scope.version", FieldContext: telemetrytypes.FieldContextUnspecified},
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
{
// A scope attribute whose own name literally carries a `scope.` prefix (`scope.prefixed`,
// normalized to {prefixed, scope}) resolves to that attribute in a SELECT even without a
// filter: getKeySelectors emits the reconstructed `scope.prefixed` selector so the metadata
// fetch surfaces it and AdjustKey recovers the full name. Without it the `scope.` prefix is
// lost and it wrongly reads `scope.attributes.prefixed`.
name: "scope-prefixed attribute in selectFields resolves without a filter",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.prefixed": {
{
Name: "scope.prefixed",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "prefixed", FieldContext: telemetrytypes.FieldContextScope},
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.attributes.`scope.prefixed` IS NOT NULL, scope.attributes.`scope.prefixed`::String, NULL) AS `__SELECT_KEY_3_scope.prefixed` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
{
// A scope-context key whose name matches a declared scope path resolves to that
// declared path (scope.name), not the span `name` column and not an undeclared
// scope attribute. getTracesKeys surfaces the declared path as an intrinsic key
// (metadata.go), which shadows the same-named span intrinsic.
name: "scope-context name resolves to the declared scope path",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {
{
Name: "scope.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"name": {
{
Name: "name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "name", FieldContext: telemetrytypes.FieldContextScope},
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
{
// span.scope.name (span context, name "scope.name") resolves to the declared
// scope path scope.name, not a span attribute literally named scope.name.
name: "span-context scope.name in selectFields resolves to the declared scope path",
requestType: qbtypes.RequestTypeRaw,
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "scope.name", FieldContext: telemetrytypes.FieldContextSpan},
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
},
}
for _, c := range cases {

View File

@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
`CASE
// WHEN tagType = 'spanfield' THEN 1
WHEN tagType = 'resource' THEN 2
// WHEN tagType = 'scope' THEN 3
WHEN tagType = 'scope' THEN 3
WHEN tagType = 'tag' THEN 4
ELSE 5
END as priority`,

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"regexp"
"strings"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
@@ -75,35 +74,6 @@ func (c *conditionBuilder) conditionForSearch(
return []string{sb.Or(conditions...)}, nil, nil
}
// numberAttributeIndexPredicate returns what an equality on a numeric attribute implies over
// mapValues(attributes_number), which its bloom filter indexes while the subscript the comparison
// reads matches nothing. The paired mapContains is what makes membership hold for the zero default.
func numberAttributeIndexPredicate(columns []*schema.Column, value any, sb *sqlbuilder.SelectBuilder) string {
if len(columns) != 1 || columns[0].Name != LogsV2AttributesNumberColumn {
return ""
}
// a non-numeric value means the collision handler compared the column as text, where an
// Array(Float64) membership check has no supertype
switch value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
return fmt.Sprintf("has(mapValues(%s), %s)", LogsV2AttributesNumberColumn, sb.Var(value))
}
return ""
}
// stringAttributeIndexPredicate returns the raw-value match a case-insensitive one implies when
// the pattern holds no ASCII letter, LOWER being the identity on those bytes. A letter breaks it:
// an `a` in the pattern may have come from an `A` in the value.
func stringAttributeIndexPredicate(columns []*schema.Column, fieldExpression, pattern string, sb *sqlbuilder.SelectBuilder) string {
if len(columns) != 1 || columns[0].Name != LogsV2AttributesStringColumn {
return ""
}
if strings.ContainsFunc(pattern, func(r rune) bool { return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') }) {
return ""
}
return sb.Like(fieldExpression, pattern)
}
// isBodyJSONSearch reports whether a key addresses a path within the body JSON. Only
// an explicit Body context qualifies; a bare, context-less `body` (e.g. full-text
// `count_distinct(body)` or `body EXISTS`) is a full-text match, not a `$.body` path.
@@ -135,14 +105,14 @@ func (c *conditionBuilder) conditionForArrayFunction(
"function `%s` supports only body JSON search", operator.FunctionName()).WithUrl(functionBodyJSONSearchDocURL)
}
element := value
needle := value
if args, ok := value.([]any); ok && len(args) > 0 {
element = args[0]
needle = args[0]
}
if c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
// JSON access plan: data-type collision handling, nested array paths.
valueType, element := InferDataType(element, operator, key)
valueType, needle := InferDataType(needle, operator, key)
// A not-found (synthesized) body path carries no metadata plan; build an exhaustive
// one so the query runs against the underlying data (with the not-found warning)
// instead of erroring, matching the regular-operator path.
@@ -153,44 +123,23 @@ func (c *conditionBuilder) conditionForArrayFunction(
}
key = keyCopy
}
return NewJSONConditionBuilder(key, valueType).buildArrayFunctionCondition(operator, element, sb)
return NewJSONConditionBuilder(key, valueType).buildArrayFunctionCondition(operator, needle, sb)
}
return c.legacyArrayFunctionCondition(key, operator, element, sb), nil
}
// legacyArrayFunctionCondition builds the has-family comparison over the plain body string, with
// what it implies over the indexed LOWER(body): the path, since the extraction yields nothing for an
// absent one, and each element, since it must appear in the text. The comparison decides the row.
func (c *conditionBuilder) legacyArrayFunctionCondition(
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
element any,
sb *sqlbuilder.SelectBuilder,
) string {
// type-matched array extraction, OR-ed with a scalar comparison for a scalar body value
// (coalesced to false so NOT has() matches missing-key rows).
elemType := legacyElemType(element)
// legacy string-body path: type-matched array extraction, OR-ed with a scalar comparison
// for a scalar body value (coalesced to false so NOT has() matches missing-key rows).
elemType := legacyElemType(needle)
arrayExpr := getBodyJSONArrayKey(key, elemType)
scalarExpr, scalarGuard, hasScalar := getBodyJSONScalarKey(key, elemType)
elements, isList := element.([]any)
if !isList {
elements = []any{element}
}
vals := make([]any, len(elements))
for i, v := range elements {
vals[i] = legacyCoerceElement(v, elemType)
}
var cond string
switch {
case isList:
// Pin the element array type to the array it is tested against; scalar fallback below coerces value-level.
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, castElementArray(elemType, sb.Var(vals)))
if list, ok := needle.([]any); ok {
vals := make([]any, len(list))
for i, v := range list {
vals[i] = legacyCoerceNeedle(v, elemType)
}
// Pin the needle array type to the haystack; scalar fallback below coerces value-level.
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, castNeedleArray(elemType, sb.Var(vals)))
if !hasScalar {
cond = arrayCond
break
return arrayCond, nil
}
var membership string
if operator == qbtypes.FilterOperatorHasAll {
@@ -202,75 +151,19 @@ func (c *conditionBuilder) legacyArrayFunctionCondition(
} else {
membership = sb.In(scalarExpr, vals...)
}
cond = fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(membership, scalarGuard))
default:
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(vals[0]))
if !hasScalar {
cond = arrayCond
break
}
cond = fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(sb.E(scalarExpr, vals[0]), scalarGuard))
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(membership, scalarGuard)), nil
}
predicates := []string{cond}
if predicate := bodyIndexPredicate(bodyPathLiterals(key), sb); predicate != "" {
predicates = append(predicates, predicate)
typedNeedle := legacyCoerceNeedle(needle, elemType)
arrayCond := fmt.Sprintf("%s(%s, %s)", operator.FunctionName(), arrayExpr, sb.Var(typedNeedle))
if !hasScalar {
return arrayCond, nil
}
// only strings imply text: the family compares at the element type it infers, so a quoted
// number is still a number here and its digits need not appear in the body
if elemType == telemetrytypes.FieldDataTypeString {
predicates = append(predicates, elementLiterals(operator, elements, sb)...)
}
if len(predicates) > 1 {
return sb.And(predicates...)
}
return cond
return fmt.Sprintf("(%s OR ifNull(%s, false))", arrayCond, sb.And(sb.E(scalarExpr, typedNeedle), scalarGuard)), nil
}
// elementLiterals returns what the elements of a has-family filter imply about the body text. has
// and hasAll require every one, so each becomes its own predicate; hasAny requires only one, so the
// arms are ORed - and an element yielding no literal leaves that OR unassertable.
func elementLiterals(operator qbtypes.FilterOperator, elements []any, sb *sqlbuilder.SelectBuilder) []string {
if operator == qbtypes.FilterOperatorHasAny {
// resolve every element before binding anything: one unusable element voids the whole OR
runSets := make([][]string, 0, len(elements))
for _, v := range elements {
str, ok := v.(string)
if !ok {
return nil
}
runs := jsonTextRuns(str)
if len(runs) == 0 {
return nil
}
runSets = append(runSets, runs)
}
arms := make([]string, 0, len(runSets))
for _, runs := range runSets {
arms = append(arms, bodyIndexPredicate(runs, sb))
}
if len(arms) == 0 {
return nil
}
return []string{sb.Or(arms...)}
}
var predicates []string
for _, v := range elements {
str, ok := v.(string)
if !ok {
continue
}
if predicate := bodyIndexPredicate(jsonTextRuns(str), sb); predicate != "" {
predicates = append(predicates, predicate)
}
}
return predicates
}
// castElementArray pins an Int64 element array to Array(Int64) so it matches the Array(Nullable(Int64))
// it is tested against; without it an element >= 2^32 binds as Array(UInt64) and hasAny/hasAll error (code 386).
func castElementArray(elemType telemetrytypes.FieldDataType, arg string) string {
// castNeedleArray pins an Int64 needle array to Array(Int64) so it matches the Array(Nullable(Int64))
// haystack; without it a needle >= 2^32 binds as Array(UInt64) and hasAny/hasAll error (code 386).
func castNeedleArray(elemType telemetrytypes.FieldDataType, arg string) string {
if elemType == telemetrytypes.FieldDataTypeInt64 {
return fmt.Sprintf("CAST(%s AS Array(Int64))", arg)
}
@@ -298,24 +191,24 @@ func (c *conditionBuilder) conditionForHasToken(
value any,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
// hasToken takes a single token; unwrap it from the function-argument slice.
token := value
// hasToken takes a single needle; unwrap it from the function-argument slice.
needle := value
if args, ok := value.([]any); ok && len(args) > 0 {
token = args[0]
needle = args[0]
}
// hasToken matches string tokens only.
tokenStr, ok := token.(string)
needleStr, ok := needle.(string)
if !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` expects value parameter to be a string").WithUrl(hasTokenFunctionDocURL)
}
// A multi-token value makes CH hasToken error (code 36); reject up front as a 400. Both modes flow here.
if sep, found := firstTokenSeparator(tokenStr); found {
// A multi-token needle makes CH hasToken error (code 36); reject up front as a 400. Both modes flow here.
if sep, found := firstTokenSeparator(needleStr); found {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` matches a single whole token, but %q contains the separator %q; use a substring filter (e.g. `body CONTAINS '%s'`) to search across separators",
tokenStr, sep, tokenStr).WithUrl(hasTokenFunctionDocURL)
needleStr, sep, needleStr).WithUrl(hasTokenFunctionDocURL)
}
bodyJSONEnabled := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
@@ -326,7 +219,7 @@ func (c *conditionBuilder) conditionForHasToken(
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports body field as first parameter").WithUrl(hasTokenFunctionDocURL)
}
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", LogsV2BodyColumn, sb.Var(token)), nil
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", LogsV2BodyColumn, sb.Var(needle)), nil
}
// JSON mode: a bare body/body.message key searches the body.message column; any other body
@@ -335,7 +228,7 @@ func (c *conditionBuilder) conditionForHasToken(
// falls through and emits dynamicElement over the already-typed String column, which errors.
if key.Name == LogsV2BodyColumn || key.Name == bodyMessageField ||
(key.FieldContext == telemetrytypes.FieldContextBody && key.Name == messageSubField) {
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", bodyMessageField, sb.Var(token)), nil
return fmt.Sprintf("hasToken(LOWER(%s), LOWER(%s))", bodyMessageField, sb.Var(needle)), nil
}
if key.FieldContext == telemetrytypes.FieldContextBody {
// A not-found (synthesized) body path carries no metadata plan; build an exhaustive
@@ -347,7 +240,7 @@ func (c *conditionBuilder) conditionForHasToken(
}
key = keyCopy
}
return NewJSONConditionBuilder(key, telemetrytypes.FieldDataTypeString).buildTokenFunctionCondition(token, sb)
return NewJSONConditionBuilder(key, telemetrytypes.FieldDataTypeString).buildTokenFunctionCondition(needle, sb)
}
return "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"function `hasToken` only supports the body field or a body JSON string field as first parameter").WithUrl(hasTokenFunctionDocURL)
@@ -376,9 +269,6 @@ func (c *conditionBuilder) conditionForResolvedKey(
return "", err
}
useJSONBody := c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
legacyBodyJSONSearch := isBodyJSONSearch(key, columns) && !useJSONBody
// has/hasAny/hasAll take the body-JSON path, not the normal operator paths.
if operator.IsArrayFunctionOperator() {
return c.conditionForArrayFunction(ctx, orgID, key, operator, value, columns, sb)
@@ -386,7 +276,7 @@ func (c *conditionBuilder) conditionForResolvedKey(
// TODO(Piyush): Update this to support multiple JSON columns based on evolutions
for _, column := range columns {
if column.Type.GetType() == schema.ColumnTypeEnumJSON && isBodyJSONSearch(key, columns) && useJSONBody && key.Name != messageSubField {
if column.Type.GetType() == schema.ColumnTypeEnumJSON && isBodyJSONSearch(key, columns) && c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) && key.Name != messageSubField {
valueType, value := InferDataType(value, operator, key)
if len(key.JSONPlan) == 0 {
keyCopy := telemetrytypes.NewTelemetryFieldKey(key.Name, key.FieldContext, key.FieldDataType)
@@ -415,83 +305,19 @@ func (c *conditionBuilder) conditionForResolvedKey(
}
// Check if this is a body JSON search (legacy string-body path, JSON flag off).
if legacyBodyJSONSearch {
return c.conditionForLegacyBodyJSON(ctx, orgID, startNs, endNs, key, operator, value, columns, sb)
if isBodyJSONSearch(key, columns) && !c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
fieldExpression, value = GetBodyJSONKey(ctx, key, operator, value)
}
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
return c.conditionForOperator(ctx, orgID, startNs, endNs, key, operator, value, columns, fieldExpression, sb)
}
// conditionForLegacyBodyJSON renders a filter over a path inside the plain string body, with what it
// implies over the indexed LOWER(body) - nothing such a filter compares matches an index expression.
func (c *conditionBuilder) conditionForLegacyBodyJSON(
ctx context.Context,
orgID valuer.UUID,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
columns []*schema.Column,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
if operator == qbtypes.FilterOperatorExists || operator == qbtypes.FilterOperatorNotExists {
exists := GetBodyJSONKeyForExists(ctx, key, operator, value)
if operator == qbtypes.FilterOperatorNotExists {
// matches the rows without the path, which say nothing about the body text
return "NOT " + exists, nil
}
if predicate := bodyIndexPredicate(bodyPathLiterals(key), sb); predicate != "" {
return sb.And(exists, predicate), nil
}
return exists, nil
}
fieldExpression, value := GetBodyJSONKey(ctx, key, operator, value)
fieldExpression, value = querybuilder.DataTypeCollisionHandledFieldName(key, value, fieldExpression, operator)
cond, err := c.conditionForOperator(ctx, orgID, startNs, endNs, key, operator, value, columns, fieldExpression, sb)
if err != nil {
return "", err
}
if predicate := bodyIndexPredicate(bodyValueLiterals(operator, value), sb); predicate != "" {
return sb.And(cond, predicate), nil
}
return cond, nil
}
// conditionForOperator renders the comparison itself, once the field expression and value have been
// resolved for the column the key landed on.
func (c *conditionBuilder) conditionForOperator(
ctx context.Context,
orgID valuer.UUID,
startNs, endNs uint64,
key *telemetrytypes.TelemetryFieldKey,
operator qbtypes.FilterOperator,
value any,
columns []*schema.Column,
fieldExpression string,
sb *sqlbuilder.SelectBuilder,
) (string, error) {
// make use of case insensitive index for body
if fieldExpression == "body" || fieldExpression == messageSubColumn {
switch operator {
case qbtypes.FilterOperatorEqual:
// Bloom filters index lower(body), not the column; `=` still decides the row.
if _, ok := value.(string); ok && fieldExpression == LogsV2BodyColumn {
return sb.And(
sb.E(fieldExpression, value),
fmt.Sprintf("LOWER(%s) = LOWER(%s)", fieldExpression, sb.Var(value)),
), nil
}
case qbtypes.FilterOperatorLike:
if _, ok := value.(string); ok && fieldExpression == LogsV2BodyColumn {
return sb.And(
sb.Like(fieldExpression, value),
sb.ILike(fieldExpression, value),
), nil
}
return sb.ILike(fieldExpression, value), nil
case qbtypes.FilterOperatorNotLike:
return sb.NotILike(fieldExpression, value), nil
case qbtypes.FilterOperatorRegexp:
// Note: Escape $$ to $$$$ to avoid sqlbuilder interpreting materialized $ signs
// Only needed because we are using sprintf instead of sb.Match (not implemented in sqlbuilder)
@@ -507,9 +333,6 @@ func (c *conditionBuilder) conditionForOperator(
switch operator {
// regular operators
case qbtypes.FilterOperatorEqual:
if predicate := numberAttributeIndexPredicate(columns, value, sb); predicate != "" {
return sb.And(sb.E(fieldExpression, value), predicate), nil
}
return sb.E(fieldExpression, value), nil
case qbtypes.FilterOperatorNotEqual:
return sb.NE(fieldExpression, value), nil
@@ -528,16 +351,17 @@ func (c *conditionBuilder) conditionForOperator(
case qbtypes.FilterOperatorNotLike:
return sb.NotLike(fieldExpression, value), nil
case qbtypes.FilterOperatorILike:
if pattern, ok := value.(string); ok {
if predicate := stringAttributeIndexPredicate(columns, fieldExpression, pattern, sb); predicate != "" {
return sb.And(sb.ILike(fieldExpression, pattern), predicate), nil
}
}
return sb.ILike(fieldExpression, value), nil
case qbtypes.FilterOperatorNotILike:
return sb.NotILike(fieldExpression, value), nil
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
if isBodyJSONSearch(key, columns) && !c.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
if operator == qbtypes.FilterOperatorExists {
return GetBodyJSONKeyForExists(ctx, key, operator, value), nil
}
return "NOT " + GetBodyJSONKeyForExists(ctx, key, operator, value), nil
}
pred, err := querybuilder.ExistsExpression(columns, key, startNs, endNs, fieldExpression, operator == qbtypes.FilterOperatorExists)
if err != nil {
return "", err
@@ -545,13 +369,7 @@ func (c *conditionBuilder) conditionForOperator(
return sqlbuilder.Escape(pred), nil
case qbtypes.FilterOperatorContains:
// The map value indexes are over raw mapValues, which a case-insensitive match reaches only
// for the patterns stringAttributeIndexPredicate can assert the raw value from.
pattern := fmt.Sprintf("%%%s%%", value)
if predicate := stringAttributeIndexPredicate(columns, fieldExpression, pattern, sb); predicate != "" {
return sb.And(sb.ILike(fieldExpression, pattern), predicate), nil
}
return sb.ILike(fieldExpression, pattern), nil
return sb.ILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
case qbtypes.FilterOperatorNotContains:
return sb.NotILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil

View File

@@ -2,7 +2,6 @@ package logstelemetryschema
import (
"context"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"testing"
"time"
@@ -169,9 +168,9 @@ func TestConditionFor(t *testing.T) {
FieldContext: telemetrytypes.FieldContextLog,
},
operator: qbtypes.FilterOperatorEqual,
value: "Error Message",
expectedSQL: "(body = ? AND LOWER(body) = LOWER(?))",
expectedArgs: []any{"Error Message", "Error Message"},
value: "error message",
expectedSQL: "body = ?",
expectedArgs: []any{"error message"},
expectedError: nil,
},
{
@@ -208,8 +207,8 @@ func TestConditionFor(t *testing.T) {
},
operator: qbtypes.FilterOperatorLike,
value: "%error%",
expectedSQL: "(body LIKE ? AND LOWER(body) LIKE LOWER(?))",
expectedArgs: []any{"%error%", "%error%"},
expectedSQL: "LOWER(body) LIKE LOWER(?)",
expectedArgs: []any{"%error%"},
expectedError: nil,
},
{
@@ -220,7 +219,7 @@ func TestConditionFor(t *testing.T) {
},
operator: qbtypes.FilterOperatorNotLike,
value: "%error%",
expectedSQL: "body NOT LIKE ?",
expectedSQL: "LOWER(body) NOT LIKE LOWER(?)",
expectedArgs: []any{"%error%"},
expectedError: nil,
},
@@ -259,8 +258,8 @@ func TestConditionFor(t *testing.T) {
},
operator: qbtypes.FilterOperatorContains,
value: 521509198310,
expectedSQL: "(LOWER(attributes_string['user.id']) LIKE LOWER(?) AND attributes_string['user.id'] LIKE ?)",
expectedArgs: []any{"%521509198310%", "%521509198310%"},
expectedSQL: "LOWER(attributes_string['user.id']) LIKE LOWER(?)",
expectedArgs: []any{"%521509198310%"},
expectedError: nil,
},
{
@@ -620,8 +619,8 @@ func TestConditionForMultipleKeys(t *testing.T) {
},
operator: qbtypes.FilterOperatorEqual,
value: "error message",
expectedSQL: "(body = ? AND LOWER(body) = LOWER(?)) AND severity_text = ?",
expectedArgs: []any{"error message", "error message", "error message"},
expectedSQL: "body = ? AND severity_text = ?",
expectedArgs: []any{"error message", "error message"},
expectedError: nil,
},
}
@@ -907,8 +906,8 @@ func TestConditionForJSONBodySearch(t *testing.T) {
}
}
// IN on the body column routes each value back through the `=` path, so every arm picks up
// the lower(body) companion — including the values a mixed-type list stringifies.
// IN on the body column routes each value back through the `=` path; the SQL it produces
// must stay what the shared IN handling produced before, including for a mixed-type list.
func TestConditionForBodyIn(t *testing.T) {
testCases := []struct {
name string
@@ -919,14 +918,14 @@ func TestConditionForBodyIn(t *testing.T) {
{
name: "strings",
values: []any{"alpha", "beta"},
expectedSQL: "((body = ? AND LOWER(body) = LOWER(?)) OR (body = ? AND LOWER(body) = LOWER(?)))",
expectedArgs: []any{"alpha", "alpha", "beta", "beta"},
expectedSQL: "(body = ? OR body = ?)",
expectedArgs: []any{"alpha", "beta"},
},
{
name: "mixed types are stringified before they reach the column",
values: []any{"alpha", float64(1), true},
expectedSQL: "((body = ? AND LOWER(body) = LOWER(?)) OR (body = ? AND LOWER(body) = LOWER(?)) OR (body = ? AND LOWER(body) = LOWER(?)))",
expectedArgs: []any{"alpha", "alpha", "1", "1", "true", "true"},
expectedSQL: "(body = ? OR body = ? OR body = ?)",
expectedArgs: []any{"alpha", "1", "true"},
},
}
@@ -955,228 +954,3 @@ func TestConditionForBodyIn(t *testing.T) {
})
}
}
// ClickHouse treats `\` as an escape only before `%`, `_` and itself.
func TestLikePatternLiterals(t *testing.T) {
testCases := []struct {
name string
pattern string
expected []string
}{
{"contains wraps a plain value", "%error%", []string{"error"}},
{"wildcards split runs", "%foo%bar%", []string{"foo", "bar"}},
{"underscore splits too", "a_b", []string{"a", "b"}},
{"escaped wildcards stay literal", `%100\%\_off%`, []string{`100%_off`}},
{"escaped backslash collapses", `%C:\\tmp%`, []string{`C:\tmp`}},
{"backslash before other chars is literal", `%C:\tmp%`, []string{`C:\tmp`}},
{"trailing backslash is literal", `%path\`, []string{`path\`}},
{"no literals at all", "%_%", nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, likePatternLiterals(tc.pattern))
})
}
}
// The literals have to hold whichever encoder wrote the body, so the runs stop at every byte
// one of them may rewrite.
func TestJSONTextRuns(t *testing.T) {
testCases := []struct {
name string
value string
expected []string
}{
{"plain text is one run", "checkout failed", []string{"checkout failed"}},
{"a run with nothing to split on is kept whole", "abc", []string{"abc"}},
{"quote splits the run", `say "hello there"`, []string{"say ", "hello there"}},
{"slash splits the run, PHP escapes it", "/api/v1/users", []string{"api", "v1", "users"}},
{"ampersand and angles split, Go escapes them", "a&b<c>dddd", []string{"a", "b", "c", "dddd"}},
{"non-ascii splits, Python escapes it", "order café latte", []string{"order caf", " latte"}},
{"newline splits", "line one\nline two", []string{"line one", "line two"}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, jsonTextRuns(tc.value))
})
}
}
func TestBodyPathLiterals(t *testing.T) {
testCases := []struct {
name string
key string
expected []string
}{
{"quoting lifts a short name over the ngram size", "id", []string{`"id"`}},
{"one literal per component", "response.status_code", []string{`"response"`, `"status_code"`}},
{"array suffixes are trimmed", "items[*].sku", []string{`"items"`, `"sku"`}},
{"every component is carried", "a.b.count", []string{`"a"`, `"b"`, `"count"`}},
{"a component an encoder may rewrite is dropped", "user/name.email", []string{`"email"`}},
{"nothing usable", "us/er.na/me", nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
key := telemetrytypes.NewTelemetryFieldKey(tc.key, telemetrytypes.FieldContextBody, telemetrytypes.FieldDataTypeUnspecified)
assert.Equal(t, tc.expected, bodyPathLiterals(key))
})
}
}
// The path literals ride on the existence assertion and the value literals on the comparison, so
// a filter carries each at most once. Nothing rides on a negated operator: it matches rows
// without the path, which say nothing about the body text.
func TestLegacyBodyIndexPredicates(t *testing.T) {
testCases := []struct {
name string
key string
operator qbtypes.FilterOperator
value any
expected string
expectedArgs []any
}{
{
name: "exists carries the path",
key: "user_id",
operator: qbtypes.FilterOperatorExists,
expected: `LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%"user\_id"%`},
},
{
name: "equality carries the value",
key: "status",
operator: qbtypes.FilterOperatorEqual,
value: "timeout_error",
expected: `LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%timeout\_error%`},
},
{
name: "contains carries the value",
key: "message",
operator: qbtypes.FilterOperatorContains,
value: "upstream refused",
expected: `LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%upstream refused%`},
},
{
name: "like carries one literal per run of the pattern",
key: "message",
operator: qbtypes.FilterOperatorLike,
value: "%conn%refused%",
expected: `LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%conn%refused%`},
},
{
name: "has carries the path and the element",
key: "tags[*]",
operator: qbtypes.FilterOperatorHas,
value: []any{"production"},
// The element rides on its own predicate rather than being pinned next to the key:
// has() over the extracted array says nothing about where in the text it sits.
expected: `LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%"tags"%`, "%production%"},
},
{
name: "hasAll carries one literal per element",
key: "tags[*]",
operator: qbtypes.FilterOperatorHasAll,
value: []any{[]any{"production", "webserver"}},
expected: `LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%"tags"%`, "%production%", "%webserver%"},
},
{
// hasAny asks for one of the elements, so the arms are ORed — ANDing them would
// demand every element be present.
name: "hasAny ORs the element literals",
key: "tags[*]",
operator: qbtypes.FilterOperatorHasAny,
value: []any{[]any{"production", "webserver"}},
expected: `LOWER(body) LIKE LOWER(?) AND (LOWER(body) LIKE LOWER(?) OR LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{`%"tags"%`, "%production%", "%webserver%"},
},
{
// one element with no usable literal voids the whole OR: the filter can still match
// through that element, so nothing about the text is implied.
name: "hasAny drops the OR when an element carries no literal",
key: "tags[*]",
operator: qbtypes.FilterOperatorHasAny,
value: []any{[]any{"production", "/"}},
expected: `LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%"tags"%`},
},
{
name: "numeric elements carry nothing",
key: "ids[*]",
operator: qbtypes.FilterOperatorHasAny,
value: []any{[]any{"9007199254740993", "9007199254740994"}},
expected: `LOWER(body) LIKE LOWER(?)`,
expectedArgs: []any{`%"ids"%`},
},
{
name: "a number carries nothing",
key: "user_id",
operator: qbtypes.FilterOperatorEqual,
value: int64(123),
},
{
// IN delegates to `=`, so the literal rides each arm rather than the IN itself
name: "IN carries one literal per arm it delegates to",
key: "status",
operator: qbtypes.FilterOperatorIn,
value: []any{"timeout_error", "conn_refused"},
expected: `(JSON_VALUE(body, '$."status"') = ? AND LOWER(body) LIKE LOWER(?)) OR (JSON_VALUE(body, '$."status"') = ? AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{`%timeout\_error%`, `%conn\_refused%`},
},
{
name: "not equal carries nothing",
key: "status",
operator: qbtypes.FilterOperatorNotEqual,
value: "timeout_error",
},
{
name: "not exists carries nothing",
key: "user_id",
operator: qbtypes.FilterOperatorNotExists,
},
{
name: "not contains carries nothing",
key: "message",
operator: qbtypes.FilterOperatorNotContains,
value: "upstream refused",
},
}
fl := flaggertest.New(t)
cb := NewConditionBuilder(NewFieldMapper(fl), fl)
ctx := context.Background()
columns := []*schema.Column{logsV2Columns[LogsV2BodyColumn]}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("1").From("t")
key := telemetrytypes.NewTelemetryFieldKey(tc.key, telemetrytypes.FieldContextBody, telemetrytypes.FieldDataTypeUnspecified)
var cond string
var err error
if tc.operator.IsArrayFunctionOperator() {
cond, err = cb.conditionForArrayFunction(ctx, valuer.UUID{}, key, tc.operator, tc.value, columns, sb)
} else {
cond, err = cb.conditionForLegacyBodyJSON(ctx, valuer.UUID{}, 0, 0, key, tc.operator, tc.value, columns, sb)
}
require.NoError(t, err)
sb.Where(cond)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
if tc.expected == "" {
assert.NotContains(t, query, "LOWER(body) LIKE")
return
}
assert.Contains(t, query, tc.expected)
assert.Subset(t, args, tc.expectedArgs)
})
}
}

View File

@@ -44,169 +44,168 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
category: "json",
query: "has(body.requestor_list[*], 'index_service')",
shouldPass: true,
expectedQuery: `WHERE ((has(JSONExtract(JSON_QUERY(body, '$."requestor_list"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."requestor_list"') = ? AND JSONType(body, 'requestor_list') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{"index_service", "index_service", "%\"requestor\\_list\"%", "%index\\_service%"},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."requestor_list"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."requestor_list"') = ? AND JSONType(body, 'requestor_list') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{"index_service", "index_service"},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.int_numbers[*], 2)",
shouldPass: true,
expectedQuery: `WHERE ((has(JSONExtract(JSON_QUERY(body, '$."int_numbers"[*]'), 'Array(Nullable(Float64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."int_numbers"'), 'Nullable(Float64)') = ? AND JSONType(body, 'int_numbers') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{float64(2), float64(2), "%\"int\\_numbers\"%"},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."int_numbers"[*]'), 'Array(Nullable(Float64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."int_numbers"'), 'Nullable(Float64)') = ? AND JSONType(body, 'int_numbers') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{float64(2), float64(2)},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.bool[*], true)",
shouldPass: true,
expectedQuery: `WHERE ((has(JSONExtract(JSON_QUERY(body, '$."bool"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."bool"') = ? AND JSONType(body, 'bool') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{"true", "true", "%\"bool\"%"},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."bool"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."bool"') = ? AND JSONType(body, 'bool') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{"true", "true"},
expectedErrorContains: "",
},
{
category: "json",
query: "NOT has(body.nested_num[*].float_nums[*], 2.2)",
shouldPass: true,
expectedQuery: `WHERE NOT ((has(JSONExtract(JSON_QUERY(body, '$."nested_num"[*]."float_nums"[*]'), 'Array(Nullable(Float64))'), ?) AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{float64(2.2), "%\"nested\\_num\"%\"float\\_nums\"%"},
expectedQuery: `WHERE NOT (has(JSONExtract(JSON_QUERY(body, '$."nested_num"[*]."float_nums"[*]'), 'Array(Nullable(Float64))'), ?))`,
expectedArgs: []any{float64(2.2)},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.tags, 'production')",
shouldPass: true,
expectedQuery: `WHERE ((has(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') = ? AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{"production", "production", "%\"tags\"%", "%production%"},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') = ? AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{"production", "production"},
expectedErrorContains: "",
},
{
category: "json",
query: "hasAny(body.tags, ['critical', 'test'])",
shouldPass: true,
expectedQuery: `WHERE ((hasAny(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') IN (?, ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?) AND (LOWER(body) LIKE LOWER(?) OR LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{[]any{"critical", "test"}, "critical", "test", "%\"tags\"%", "%critical%", "%test%"},
expectedQuery: `WHERE (hasAny(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull((JSON_VALUE(body, '$."tags"') IN (?, ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{"critical", "test"}, "critical", "test"},
expectedErrorContains: "",
},
{
category: "json",
query: "hasAll(body.tags, ['production', 'web'])",
shouldPass: true,
expectedQuery: `WHERE ((hasAll(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull(((JSON_VALUE(body, '$."tags"') = ? AND JSON_VALUE(body, '$."tags"') = ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{[]any{"production", "web"}, "production", "web", "%\"tags\"%", "%production%", "%web%"},
expectedQuery: `WHERE (hasAll(JSONExtract(JSON_QUERY(body, '$."tags"[*]'), 'Array(Nullable(String))'), ?) OR ifNull(((JSON_VALUE(body, '$."tags"') = ? AND JSON_VALUE(body, '$."tags"') = ?) AND JSONType(body, 'tags') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{"production", "web"}, "production", "web"},
expectedErrorContains: "",
},
{
category: "json",
query: "has(body.ids, \"200\")",
shouldPass: true,
expectedQuery: `WHERE ((has(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{int64(200), int64(200), "%\"ids\"%"},
expectedQuery: `WHERE (has(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), ?) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{int64(200), int64(200)},
expectedErrorContains: "",
},
{
// Big-int element CAST to Array(Int64) to match the array it is tested against (else 386).
// Big-int needle CAST to Array(Int64) to match the haystack (else 386).
category: "json",
query: `hasAny(body.ids, ['9007199254740993', '9007199254740994'])`,
shouldPass: true,
expectedQuery: `WHERE ((hasAny(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') IN (?, ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994), "%\"ids\"%"},
expectedQuery: `WHERE (hasAny(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') IN (?, ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
expectedErrorContains: "",
},
{
category: "json",
query: `hasAll(body.ids, ['9007199254740993', '9007199254740994'])`,
shouldPass: true,
expectedQuery: `WHERE ((hasAll(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull(((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false)) AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994), "%\"ids\"%"},
expectedQuery: `WHERE (hasAll(JSONExtract(JSON_QUERY(body, '$."ids"[*]'), 'Array(Nullable(Int64))'), CAST(? AS Array(Int64))) OR ifNull(((JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ? AND JSONExtract(JSON_VALUE(body, '$."ids"'), 'Nullable(Int64)') = ?) AND JSONType(body, 'ids') NOT IN ('Array', 'Object', 'Null')), false))`,
expectedArgs: []any{[]any{int64(9007199254740993), int64(9007199254740994)}, int64(9007199254740993), int64(9007199254740994)},
expectedErrorContains: "",
},
{
category: "json",
query: "body.message = hello",
shouldPass: true,
expectedQuery: `WHERE ((JSON_VALUE(body, '$."message"') = ? AND LOWER(body) LIKE LOWER(?)) AND (JSON_EXISTS(body, '$."message"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{"hello", "%hello%", "%\"message\"%"},
expectedQuery: `WHERE (JSON_VALUE(body, '$."message"') = ? AND JSON_EXISTS(body, '$."message"'))`,
expectedArgs: []any{"hello"},
expectedErrorContains: "",
},
{
category: "json",
query: "body.status = 1",
shouldPass: true,
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."status"'), 'Float64') = ? AND (JSON_EXISTS(body, '$."status"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{float64(1), "%\"status\"%"},
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."status"'), 'Float64') = ? AND JSON_EXISTS(body, '$."status"'))`,
expectedArgs: []any{float64(1)},
expectedErrorContains: "",
},
{
category: "json",
query: "body.status = 1.1",
shouldPass: true,
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."status"'), 'Float64') = ? AND (JSON_EXISTS(body, '$."status"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{float64(1.1), "%\"status\"%"},
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."status"'), 'Float64') = ? AND JSON_EXISTS(body, '$."status"'))`,
expectedArgs: []any{float64(1.1)},
expectedErrorContains: "",
},
{
category: "json",
query: "body.boolkey = true",
shouldPass: true,
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."boolkey"'), 'Bool') = ? AND (JSON_EXISTS(body, '$."boolkey"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{true, "%\"boolkey\"%"},
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."boolkey"'), 'Bool') = ? AND JSON_EXISTS(body, '$."boolkey"'))`,
expectedArgs: []any{true},
expectedErrorContains: "",
},
{
category: "json",
query: "body.status > 200",
shouldPass: true,
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."status"'), 'Float64') > ? AND (JSON_EXISTS(body, '$."status"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{float64(200), "%\"status\"%"},
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."status"'), 'Float64') > ? AND JSON_EXISTS(body, '$."status"'))`,
expectedArgs: []any{float64(200)},
expectedErrorContains: "",
},
{
category: "json",
query: "body.message REGEXP 'a*'",
shouldPass: true,
expectedQuery: `WHERE (match(JSON_VALUE(body, '$."message"'), ?) AND (JSON_EXISTS(body, '$."message"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{"a*", "%\"message\"%"},
expectedQuery: `WHERE (match(JSON_VALUE(body, '$."message"'), ?) AND JSON_EXISTS(body, '$."message"'))`,
expectedArgs: []any{"a*"},
expectedErrorContains: "",
},
{
category: "json",
query: `body.message CONTAINS "hello 'world'"`,
shouldPass: true,
expectedQuery: `WHERE ((LOWER(JSON_VALUE(body, '$."message"')) LIKE LOWER(?) AND LOWER(body) LIKE LOWER(?)) AND (JSON_EXISTS(body, '$."message"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{"%hello 'world'%", "%hello 'world'%", "%\"message\"%"},
expectedQuery: `WHERE (LOWER(JSON_VALUE(body, '$."message"')) LIKE LOWER(?) AND JSON_EXISTS(body, '$."message"'))`,
expectedArgs: []any{"%hello 'world'%"},
expectedErrorContains: "",
},
{
category: "json",
query: `body.message EXISTS`,
shouldPass: true,
expectedQuery: `WHERE (JSON_EXISTS(body, '$."message"') AND LOWER(body) LIKE LOWER(?))`,
expectedArgs: []any{"%\"message\"%"},
expectedQuery: `WHERE JSON_EXISTS(body, '$."message"')`,
expectedErrorContains: "",
},
{
category: "json",
query: `body.name IN ('hello', 'world')`,
shouldPass: true,
expectedQuery: `WHERE (((JSON_VALUE(body, '$."name"') = ? AND LOWER(body) LIKE LOWER(?)) OR (JSON_VALUE(body, '$."name"') = ? AND LOWER(body) LIKE LOWER(?))) AND (JSON_EXISTS(body, '$."name"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{"hello", "%hello%", "world", "%world%", "%\"name\"%"},
expectedQuery: `WHERE ((JSON_VALUE(body, '$."name"') = ? OR JSON_VALUE(body, '$."name"') = ?) AND JSON_EXISTS(body, '$."name"'))`,
expectedArgs: []any{"hello", "world"},
expectedErrorContains: "",
},
{
category: "json",
query: `body.value IN (200, 300)`,
shouldPass: true,
expectedQuery: `WHERE ((JSONExtract(JSON_VALUE(body, '$."value"'), 'Float64') = ? OR JSONExtract(JSON_VALUE(body, '$."value"'), 'Float64') = ?) AND (JSON_EXISTS(body, '$."value"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{float64(200), float64(300), "%\"value\"%"},
expectedQuery: `WHERE ((JSONExtract(JSON_VALUE(body, '$."value"'), 'Float64') = ? OR JSONExtract(JSON_VALUE(body, '$."value"'), 'Float64') = ?) AND JSON_EXISTS(body, '$."value"'))`,
expectedArgs: []any{float64(200), float64(300)},
expectedErrorContains: "",
},
{
category: "json",
query: "body.key-with-hyphen = true",
shouldPass: true,
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."key-with-hyphen"'), 'Bool') = ? AND (JSON_EXISTS(body, '$."key-with-hyphen"') AND LOWER(body) LIKE LOWER(?)))`,
expectedArgs: []any{true, "%\"key-with-hyphen\"%"},
expectedQuery: `WHERE (JSONExtract(JSON_VALUE(body, '$."key-with-hyphen"'), 'Bool') = ? AND JSON_EXISTS(body, '$."key-with-hyphen"'))`,
expectedArgs: []any{true},
expectedErrorContains: "",
},
}

View File

@@ -495,32 +495,32 @@ func TestFilterExprLogs(t *testing.T) {
category: "FREETEXT with parentheses",
query: "error (status.code=500 OR status.code=503)",
shouldPass: true,
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND ((((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')) OR ((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')))))",
expectedArgs: []any{"error", float64(500), float64(500), float64(503), float64(503)},
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')))))",
expectedArgs: []any{"error", float64(500), float64(503)},
expectedErrorContains: "",
},
{
category: "FREETEXT with parentheses",
query: "(status.code=500 OR status.code=503) error",
shouldPass: true,
expectedQuery: "WHERE (((((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')) OR ((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')))) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{float64(500), float64(500), float64(503), float64(503), "error"},
expectedQuery: "WHERE ((((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')))) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{float64(500), float64(503), "error"},
expectedErrorContains: "",
},
{
category: "FREETEXT with parentheses",
query: "error AND (status.code=500 OR status.code=503)",
shouldPass: true,
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND ((((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')) OR ((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')))))",
expectedArgs: []any{"error", float64(500), float64(500), float64(503), float64(503)},
expectedQuery: "WHERE (match(LOWER(body), LOWER(?)) AND (((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')))))",
expectedArgs: []any{"error", float64(500), float64(503)},
expectedErrorContains: "",
},
{
category: "FREETEXT with parentheses",
query: "(status.code=500 OR status.code=503) AND error",
shouldPass: true,
expectedQuery: "WHERE (((((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')) OR ((toFloat64(attributes_number['status.code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status.code')))) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{float64(500), float64(500), float64(503), float64(503), "error"},
expectedQuery: "WHERE ((((toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')) OR (toFloat64(attributes_number['status.code']) = ? AND mapContains(attributes_number, 'status.code')))) AND match(LOWER(body), LOWER(?)))",
expectedArgs: []any{float64(500), float64(503), "error"},
expectedErrorContains: "",
},
@@ -737,8 +737,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: "greater>than",
shouldPass: true,
expectedQuery: `WHERE ((attributes_string['greater'] > ? AND mapContains(attributes_string, 'greater')) OR (JSON_VALUE(body, '$."greater"') > ? AND (JSON_EXISTS(body, '$."greater"') AND LOWER(body) LIKE LOWER(?))))`,
expectedArgs: []any{"than", "than", "%\"greater\"%"},
expectedQuery: `WHERE ((attributes_string['greater'] > ? AND mapContains(attributes_string, 'greater')) OR (JSON_VALUE(body, '$."greater"') > ? AND JSON_EXISTS(body, '$."greater"')))`,
expectedArgs: []any{"than", "than"},
expectedErrorContains: "",
},
{
@@ -753,8 +753,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: "less<than",
shouldPass: true,
expectedQuery: `WHERE ((attributes_string['less'] < ? AND mapContains(attributes_string, 'less')) OR (JSON_VALUE(body, '$."less"') < ? AND (JSON_EXISTS(body, '$."less"') AND LOWER(body) LIKE LOWER(?))))`,
expectedArgs: []any{"than", "than", "%\"less\"%"},
expectedQuery: `WHERE ((attributes_string['less'] < ? AND mapContains(attributes_string, 'less')) OR (JSON_VALUE(body, '$."less"') < ? AND JSON_EXISTS(body, '$."less"')))`,
expectedArgs: []any{"than", "than"},
expectedErrorContains: "",
},
{
@@ -809,8 +809,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Key-operator-value boundary",
query: "user=admin",
shouldPass: true,
expectedQuery: `WHERE ((attributes_string['user'] = ? AND mapContains(attributes_string, 'user')) OR ((JSON_VALUE(body, '$."user"') = ? AND LOWER(body) LIKE LOWER(?)) AND (JSON_EXISTS(body, '$."user"') AND LOWER(body) LIKE LOWER(?))))`,
expectedArgs: []any{"admin", "admin", "%admin%", "%\"user\"%"},
expectedQuery: `WHERE ((attributes_string['user'] = ? AND mapContains(attributes_string, 'user')) OR (JSON_VALUE(body, '$."user"') = ? AND JSON_EXISTS(body, '$."user"')))`,
expectedArgs: []any{"admin", "admin"},
expectedErrorContains: "",
},
{
@@ -827,16 +827,16 @@ func TestFilterExprLogs(t *testing.T) {
category: "Basic equality",
query: "status=200",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200), float64(200)},
expectedQuery: "WHERE (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200)},
expectedErrorContains: "",
},
{
category: "Basic equality",
query: "code=400",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['code']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'code'))",
expectedArgs: []any{float64(400), float64(400)},
expectedQuery: "WHERE (toFloat64(attributes_number['code']) = ? AND mapContains(attributes_number, 'code'))",
expectedArgs: []any{float64(400)},
expectedErrorContains: "",
},
{
@@ -867,8 +867,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Basic equality",
query: "count=0",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['count']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'count'))",
expectedArgs: []any{float64(0), float64(0)},
expectedQuery: "WHERE (toFloat64(attributes_number['count']) = ? AND mapContains(attributes_number, 'count'))",
expectedArgs: []any{float64(0)},
expectedErrorContains: "",
},
{
@@ -1187,16 +1187,16 @@ func TestFilterExprLogs(t *testing.T) {
category: "IN operator (parentheses)",
query: "status IN (200, 201, 202)",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?))) AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200), float64(200), float64(201), float64(201), float64(202), float64(202)},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? OR toFloat64(attributes_number['status']) = ? OR toFloat64(attributes_number['status']) = ?) AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200), float64(201), float64(202)},
expectedErrorContains: "",
},
{
category: "IN operator (parentheses)",
query: "error.code IN (404, 500, 503)",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['error.code']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['error.code']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['error.code']) = ? AND has(mapValues(attributes_number), ?))) AND mapContains(attributes_number, 'error.code'))",
expectedArgs: []any{float64(404), float64(404), float64(500), float64(500), float64(503), float64(503)},
expectedQuery: "WHERE ((toFloat64(attributes_number['error.code']) = ? OR toFloat64(attributes_number['error.code']) = ? OR toFloat64(attributes_number['error.code']) = ?) AND mapContains(attributes_number, 'error.code'))",
expectedArgs: []any{float64(404), float64(500), float64(503)},
expectedErrorContains: "",
},
{
@@ -1221,16 +1221,16 @@ func TestFilterExprLogs(t *testing.T) {
category: "IN operator (brackets)",
query: "status IN [200, 201, 202]",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?))) AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200), float64(200), float64(201), float64(201), float64(202), float64(202)},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? OR toFloat64(attributes_number['status']) = ? OR toFloat64(attributes_number['status']) = ?) AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200), float64(201), float64(202)},
expectedErrorContains: "",
},
{
category: "IN operator (brackets)",
query: "error.code IN [404, 500, 503]",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['error.code']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['error.code']) = ? AND has(mapValues(attributes_number), ?)) OR (toFloat64(attributes_number['error.code']) = ? AND has(mapValues(attributes_number), ?))) AND mapContains(attributes_number, 'error.code'))",
expectedArgs: []any{float64(404), float64(404), float64(500), float64(500), float64(503), float64(503)},
expectedQuery: "WHERE ((toFloat64(attributes_number['error.code']) = ? OR toFloat64(attributes_number['error.code']) = ? OR toFloat64(attributes_number['error.code']) = ?) AND mapContains(attributes_number, 'error.code'))",
expectedArgs: []any{float64(404), float64(500), float64(503)},
expectedErrorContains: "",
},
{
@@ -1561,15 +1561,15 @@ func TestFilterExprLogs(t *testing.T) {
expectedArgs: []any{"download"},
expectedErrorContains: "function `hasToken` expects value parameter to be a string",
},
// A multi-token value (separator/whitespace) is a clean 400, not a CH execution error.
// A multi-token needle (separator/whitespace) is a clean 400, not a CH execution error.
{
category: "hasTokenUnderscoreSeparator",
category: "hasTokenUnderscoreNeedle",
query: "hasToken(body, \"user_id\")",
shouldPass: false,
expectedErrorContains: "function `hasToken` matches a single whole token",
},
{
category: "hasTokenWhitespaceSeparator",
category: "hasTokenWhitespaceNeedle",
query: "hasToken(body, \"production node\")",
shouldPass: false,
expectedErrorContains: "function `hasToken` matches a single whole token",
@@ -1609,8 +1609,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Explicit AND",
query: "status=200 AND service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), float64(200), "api"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), "api"},
expectedErrorContains: "",
},
{
@@ -1635,8 +1635,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Explicit OR",
query: "status=200 OR status=201",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) OR ((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')))",
expectedArgs: []any{float64(200), float64(200), float64(201), float64(201)},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) OR (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')))",
expectedArgs: []any{float64(200), float64(201)},
expectedErrorContains: "",
},
{
@@ -1661,8 +1661,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "NOT with expressions",
query: "NOT status=200",
shouldPass: true,
expectedQuery: "WHERE NOT (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')))",
expectedArgs: []any{float64(200), float64(200)},
expectedQuery: "WHERE NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')))",
expectedArgs: []any{float64(200)},
expectedErrorContains: "",
},
{
@@ -1687,8 +1687,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + OR combinations",
query: "status=200 AND (service.name=\"api\" OR service.name=\"web\")",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))))",
expectedArgs: []any{float64(200), float64(200), "api", "web"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))))",
expectedArgs: []any{float64(200), "api", "web"},
expectedErrorContains: "",
},
{
@@ -1713,8 +1713,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + NOT combinations",
query: "status=200 AND NOT service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))",
expectedArgs: []any{float64(200), float64(200), "api"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))",
expectedArgs: []any{float64(200), "api"},
expectedErrorContains: "",
},
{
@@ -1731,8 +1731,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "OR + NOT combinations",
query: "NOT status=200 OR NOT service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (NOT (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status'))) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))",
expectedArgs: []any{float64(200), float64(200), "api"},
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status'))) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))",
expectedArgs: []any{float64(200), "api"},
expectedErrorContains: "",
},
{
@@ -1749,8 +1749,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + OR + NOT combinations",
query: "status=200 AND (service.name=\"api\" OR NOT duration>1000)",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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) OR NOT ((toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration'))))))",
expectedArgs: []any{float64(200), float64(200), "api", float64(1000)},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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) OR NOT ((toFloat64(attributes_number['duration']) > ? AND mapContains(attributes_number, 'duration'))))))",
expectedArgs: []any{float64(200), "api", float64(1000)},
expectedErrorContains: "",
},
{
@@ -1765,8 +1765,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "AND + OR + NOT combinations",
query: "NOT (status=200 AND service.name=\"api\") OR count>0",
shouldPass: true,
expectedQuery: "WHERE (NOT (((((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))) OR (toFloat64(attributes_number['count']) > ? AND mapContains(attributes_number, 'count')))",
expectedArgs: []any{float64(200), float64(200), "api", float64(0)},
expectedQuery: "WHERE (NOT ((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))) OR (toFloat64(attributes_number['count']) > ? AND mapContains(attributes_number, 'count')))",
expectedArgs: []any{float64(200), "api", float64(0)},
expectedErrorContains: "",
},
@@ -1775,8 +1775,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Implicit AND",
query: "status=200 service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), float64(200), "api"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), "api"},
expectedErrorContains: "",
},
{
@@ -1801,8 +1801,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Mixed implicit/explicit AND",
query: "status=200 AND service.name=\"api\" duration<1000",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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 (toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration')))",
expectedArgs: []any{float64(200), float64(200), "api", float64(1000)},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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 (toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration')))",
expectedArgs: []any{float64(200), "api", float64(1000)},
expectedErrorContains: "",
},
{
@@ -1819,8 +1819,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Simple grouping",
query: "(status=200)",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')))",
expectedArgs: []any{float64(200), float64(200)},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')))",
expectedArgs: []any{float64(200)},
expectedErrorContains: "",
},
{
@@ -1845,8 +1845,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Nested grouping",
query: "((status=200))",
shouldPass: true,
expectedQuery: "WHERE ((((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status'))))",
expectedArgs: []any{float64(200), float64(200)},
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status'))))",
expectedArgs: []any{float64(200)},
expectedErrorContains: "",
},
{
@@ -1871,8 +1871,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Complex nested grouping",
query: "(status=200 AND (service.name=\"api\" OR service.name=\"web\"))",
shouldPass: true,
expectedQuery: "WHERE ((((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))))",
expectedArgs: []any{float64(200), float64(200), "api", "web"},
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))))",
expectedArgs: []any{float64(200), "api", "web"},
expectedErrorContains: "",
},
{
@@ -1897,8 +1897,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Deep nesting",
query: "(((status=200 OR status=201) AND service.name=\"api\") OR ((status=202 OR status=203) AND service.name=\"web\"))",
shouldPass: true,
expectedQuery: "WHERE ((((((((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) OR ((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))) OR ((((((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) OR ((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))))",
expectedArgs: []any{float64(200), float64(200), float64(201), float64(201), "api", float64(202), float64(202), float64(203), float64(203), "web"},
expectedQuery: "WHERE (((((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) OR (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))) OR (((((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) OR (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))))",
expectedArgs: []any{float64(200), float64(201), "api", float64(202), float64(203), "web"},
expectedErrorContains: "",
},
{
@@ -1949,32 +1949,32 @@ func TestFilterExprLogs(t *testing.T) {
category: "Numeric values",
query: "status=200",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200), float64(200)},
expectedQuery: "WHERE (toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status'))",
expectedArgs: []any{float64(200)},
expectedErrorContains: "",
},
{
category: "Numeric values",
query: "count=0",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['count']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'count'))",
expectedArgs: []any{float64(0), float64(0)},
expectedQuery: "WHERE (toFloat64(attributes_number['count']) = ? AND mapContains(attributes_number, 'count'))",
expectedArgs: []any{float64(0)},
expectedErrorContains: "",
},
{
category: "Numeric values",
query: "duration=1000.5",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['duration']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'duration'))",
expectedArgs: []any{float64(1000.5), float64(1000.5)},
expectedQuery: "WHERE (toFloat64(attributes_number['duration']) = ? AND mapContains(attributes_number, 'duration'))",
expectedArgs: []any{float64(1000.5)},
expectedErrorContains: "",
},
{
category: "Numeric values",
query: "amount=-10.25",
shouldPass: true,
expectedQuery: "WHERE ((toFloat64(attributes_number['amount']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'amount'))",
expectedArgs: []any{float64(-10.25), float64(-10.25)},
expectedQuery: "WHERE (toFloat64(attributes_number['amount']) = ? AND mapContains(attributes_number, 'amount'))",
expectedArgs: []any{float64(-10.25)},
expectedErrorContains: "",
},
@@ -2052,8 +2052,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Nested object paths",
query: "response.body.data.items[].id=123",
shouldPass: true,
expectedQuery: `WHERE (((toFloat64(attributes_number['response.body.data.items[].id']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'response.body.data.items[].id')) OR (JSONExtract(JSON_VALUE(body, '$."response"."body"."data"."items"[*]."id"'), 'Float64') = ? AND (JSON_EXISTS(body, '$."response"."body"."data"."items"[*]."id"') AND LOWER(body) LIKE LOWER(?))))`,
expectedArgs: []any{float64(123), float64(123), float64(123), "%\"response\"%\"body\"%\"data\"%\"items\"%\"id\"%"},
expectedQuery: `WHERE ((toFloat64(attributes_number['response.body.data.items[].id']) = ? AND mapContains(attributes_number, 'response.body.data.items[].id')) OR (JSONExtract(JSON_VALUE(body, '$."response"."body"."data"."items"[*]."id"'), 'Float64') = ? AND JSON_EXISTS(body, '$."response"."body"."data"."items"[*]."id"')))`,
expectedArgs: []any{float64(123), float64(123)},
expectedErrorContains: "",
},
{
@@ -2083,29 +2083,29 @@ func TestFilterExprLogs(t *testing.T) {
category: "Operator precedence",
query: "NOT status=200 AND service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (NOT (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status'))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), float64(200), "api"}, // Should be (NOT status=200) AND service.name="api"
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status'))) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), "api"}, // Should be (NOT status=200) AND service.name="api"
},
{
category: "Operator precedence",
query: "status=200 AND service.name=\"api\" OR service.name=\"web\"",
shouldPass: true,
expectedQuery: "WHERE ((((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), float64(200), "api", "web"}, // Should be (status=200 AND service.name="api") OR service.name="web"
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)) OR (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), "api", "web"}, // Should be (status=200 AND service.name="api") OR service.name="web"
},
{
category: "Operator precedence",
query: "NOT status=200 OR NOT service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (NOT (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status'))) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))",
expectedArgs: []any{float64(200), float64(200), "api"}, // Should be (NOT status=200) OR (NOT service.name="api")
expectedQuery: "WHERE (NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status'))) OR NOT ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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)))",
expectedArgs: []any{float64(200), "api"}, // Should be (NOT status=200) OR (NOT service.name="api")
},
{
category: "Operator precedence",
query: "status=200 OR service.name=\"api\" AND level=\"ERROR\"",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) OR ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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 (attributes_string['level'] = ? AND mapContains(attributes_string, 'level'))))",
expectedArgs: []any{float64(200), float64(200), "api", "ERROR"}, // Should be status=200 OR (service.name="api" AND level="ERROR")
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) OR ((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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 (attributes_string['level'] = ? AND mapContains(attributes_string, 'level'))))",
expectedArgs: []any{float64(200), "api", "ERROR"}, // Should be status=200 OR (service.name="api" AND level="ERROR")
},
// Different whitespace patterns
@@ -2129,8 +2129,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Whitespace patterns",
query: "status=200 AND service.name=\"api\"",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), float64(200), "api"}, // Multiple spaces
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), "api"}, // Multiple spaces
},
// More Unicode characters
@@ -2365,8 +2365,8 @@ func TestFilterExprLogs(t *testing.T) {
category: "Unusual whitespace",
query: "status = 200 AND service.name = \"api\"",
shouldPass: true,
expectedQuery: "WHERE (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), float64(200), "api"},
expectedQuery: "WHERE ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status')) AND (multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? 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))",
expectedArgs: []any{float64(200), "api"},
},
{
category: "Unusual whitespace",
@@ -2426,9 +2426,9 @@ func TestFilterExprLogs(t *testing.T) {
)
`,
shouldPass: true,
expectedQuery: "WHERE ((((((((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status')) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status')))) OR (((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status')) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status')) AND NOT (((toFloat64(attributes_number['status']) = ? AND has(mapValues(attributes_number), ?)) AND mapContains(attributes_number, 'status'))))))) AND ((((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) 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) OR (((multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) = ? AND multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) IS NOT NULL) AND NOT ((multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) = ? AND multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) IS NOT NULL)))))))) AND (((((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration')) OR ((toFloat64(attributes_number['duration']) BETWEEN ? AND ? AND mapContains(attributes_number, 'duration'))))) AND ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) <> ? OR (((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) IS NOT NULL) AND (attributes_bool['is_automated_test'] = ? AND mapContains(attributes_bool, 'is_automated_test')))))))) AND NOT ((((((LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message')) OR (LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message')))) AND (attributes_string['severity'] = ? AND mapContains(attributes_string, 'severity'))))))",
expectedQuery: "WHERE ((((((((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status')) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status')))) OR (((toFloat64(attributes_number['status']) >= ? AND mapContains(attributes_number, 'status')) AND (toFloat64(attributes_number['status']) < ? AND mapContains(attributes_number, 'status')) AND NOT ((toFloat64(attributes_number['status']) = ? AND mapContains(attributes_number, 'status'))))))) AND ((((multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ? OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) 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) OR (((multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) = ? AND multiIf(resource.`service.type` IS NOT NULL, resource.`service.type`::String, mapContains(resources_string, 'service.type'), resources_string['service.type'], NULL) IS NOT NULL) AND NOT ((multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) = ? AND multiIf(resource.`service.deprecated` IS NOT NULL, resource.`service.deprecated`::String, mapContains(resources_string, 'service.deprecated'), resources_string['service.deprecated'], NULL) IS NOT NULL)))))))) AND (((((toFloat64(attributes_number['duration']) < ? AND mapContains(attributes_number, 'duration')) OR ((toFloat64(attributes_number['duration']) BETWEEN ? AND ? AND mapContains(attributes_number, 'duration'))))) AND ((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) <> ? OR (((multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) = ? AND multiIf(resource.`environment` IS NOT NULL, resource.`environment`::String, mapContains(resources_string, 'environment'), resources_string['environment'], NULL) IS NOT NULL) AND (attributes_bool['is_automated_test'] = ? AND mapContains(attributes_bool, 'is_automated_test')))))))) AND NOT ((((((LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message')) OR (LOWER(attributes_string['message']) LIKE LOWER(?) AND mapContains(attributes_string, 'message')))) AND (attributes_string['severity'] = ? AND mapContains(attributes_string, 'severity'))))))",
expectedArgs: []any{
float64(200), float64(300), float64(400), float64(500), float64(404), float64(404),
float64(200), float64(300), float64(400), float64(500), float64(404),
"api", "web", "auth",
"internal", true,
float64(1000), float64(1000), float64(5000),
@@ -2521,7 +2521,7 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
query: "body NOT LIKE 'done'",
shouldPass: true,
// lower index search on body even for LIKE
expectedQuery: "WHERE (body NOT LIKE ? AND attributes_string['body'] NOT LIKE ?)",
expectedQuery: "WHERE (LOWER(body) NOT LIKE LOWER(?) AND attributes_string['body'] NOT LIKE ?)",
expectedArgs: []any{"done", "done"},
expectedErrorContains: "",
},

View File

@@ -439,27 +439,27 @@ func (c *jsonConditionBuilder) arrayFuncScalarLeaf(node *telemetrytypes.JSONAcce
// buildTokenFunctionCondition builds a hasToken search over a body JSON string field:
// hasToken(LOWER(<elem>), LOWER(?)) wrapped in arrayExists over any array hops between the
// root and the terminal. The field must resolve to a String leaf or a String array.
func (c *jsonConditionBuilder) buildTokenFunctionCondition(token any, sb *sqlbuilder.SelectBuilder) (string, error) {
func (c *jsonConditionBuilder) buildTokenFunctionCondition(needle any, sb *sqlbuilder.SelectBuilder) (string, error) {
if len(c.key.JSONPlan) == 0 {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` could not resolve a JSON access plan for field `%s`", c.key.Name)
}
return c.buildOredRootChains(func(node *telemetrytypes.JSONAccessNode) (string, error) {
return c.tokenLeaf(node, token, sb)
return c.tokenLeaf(node, needle, sb)
}, sb)
}
// tokenLeaf builds the hasToken match at a terminal node: a direct match for a String leaf
// (coalesced to false, as in arrayFuncScalarLeaf), or an arrayExists over the elements for a
// String array leaf. hasToken is string-only, so any other element type is rejected.
func (c *jsonConditionBuilder) tokenLeaf(node *telemetrytypes.JSONAccessNode, token any, sb *sqlbuilder.SelectBuilder) (string, error) {
func (c *jsonConditionBuilder) tokenLeaf(node *telemetrytypes.JSONAccessNode, needle any, sb *sqlbuilder.SelectBuilder) (string, error) {
switch node.TerminalConfig.ElemType {
case telemetrytypes.String:
fieldExpr := fmt.Sprintf("dynamicElement(%s, 'String')", node.FieldPath())
return fmt.Sprintf("ifNull(hasToken(LOWER(%s), LOWER(%s)), false)", fieldExpr, sb.Var(token)), nil
return fmt.Sprintf("ifNull(hasToken(LOWER(%s), LOWER(%s)), false)", fieldExpr, sb.Var(needle)), nil
case telemetrytypes.ArrayString:
arrayExpr := fmt.Sprintf("dynamicElement(%s, '%s')", node.FieldPath(), node.TerminalConfig.ElemType.StringValue())
return fmt.Sprintf("arrayExists(x -> hasToken(LOWER(x), LOWER(%s)), %s)", sb.Var(token), arrayExpr), nil
return fmt.Sprintf("arrayExists(x -> hasToken(LOWER(x), LOWER(%s)), %s)", sb.Var(needle), arrayExpr), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "function `hasToken` only supports string fields; field `%s` is `%s`", c.key.Name, node.TerminalConfig.Key.FieldDataType.StringValue())
}

View File

@@ -9,8 +9,6 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
func parseStrValue(valueStr string, operator qbtypes.FilterOperator) (telemetrytypes.FieldDataType, any) {
@@ -94,114 +92,6 @@ func InferDataType(value any, operator qbtypes.FilterOperator, key *telemetrytyp
return closure(value, key)
}
// likePatternLiterals returns the runs of pattern between unescaped wildcards, with `\` escapes
// resolved; every value the pattern matches holds each run verbatim. ClickHouse treats `\` as an
// escape only before `%`, `_` and itself, so dropping it elsewhere would yield a run it never requires.
func likePatternLiterals(pattern string) []string {
var (
literals []string
run strings.Builder
)
for i := 0; i < len(pattern); i++ {
switch c := pattern[i]; c {
case '%', '_':
if run.Len() > 0 {
literals = append(literals, run.String())
run.Reset()
}
case '\\':
if i+1 >= len(pattern) {
run.WriteByte('\\')
continue
}
i++
if escaped := pattern[i]; escaped != '%' && escaped != '_' && escaped != '\\' {
run.WriteByte('\\')
}
run.WriteByte(pattern[i])
default:
run.WriteByte(c)
}
}
if run.Len() > 0 {
literals = append(literals, run.String())
}
return literals
}
// jsonEscapable reports whether a JSON encoder is free to rewrite r: `"` and `\` always, `/` by
// PHP, `<` `>` `&` by Go, non-printable ASCII by Python's ensure_ascii. The legacy body holds the
// producer's own text, so a literal spanning one of these may not be there to find.
func jsonEscapable(r rune) bool {
return r < 0x20 || r > 0x7e || strings.ContainsRune(`"\/<>&`, r)
}
// jsonTextRuns splits s at every byte an encoder may rewrite. A body whose JSON holds s contains
// each returned run verbatim, in order.
func jsonTextRuns(s string) []string {
return strings.FieldsFunc(s, jsonEscapable)
}
// bodyPathLiterals returns one literal per component of key's JSON path, quoted the way JSON writes
// an object key. A component holding a byte an encoder may rewrite is dropped; JSON writes a parent
// first, so the order carries.
func bodyPathLiterals(key *telemetrytypes.TelemetryFieldKey) []string {
var literals []string
for _, part := range strings.Split(key.Name, ".") {
if idx := strings.Index(part, "["); idx >= 0 {
part = part[:idx]
}
if literal := `"` + part + `"`; !strings.ContainsFunc(part, jsonEscapable) {
literals = append(literals, literal)
}
}
return literals
}
// bodyValueLiterals returns the literals a comparison implies in the body text. Only string
// comparisons qualify: a number is compared after JSONExtract parses it, which reads 1.23e2
// as 123, so the digits of the filter value need not appear in the body at all.
func bodyValueLiterals(operator qbtypes.FilterOperator, value any) []string {
str, ok := value.(string)
if !ok {
return nil
}
switch operator {
case qbtypes.FilterOperatorEqual, qbtypes.FilterOperatorContains:
return jsonTextRuns(str)
case qbtypes.FilterOperatorLike, qbtypes.FilterOperatorILike:
var literals []string
for _, literal := range likePatternLiterals(str) {
literals = append(literals, jsonTextRuns(literal)...)
}
return literals
}
return nil
}
// escapeLikeLiteral escapes the LIKE metacharacters so s matches as literal text. Backslash
// goes first, being the escape character itself.
func escapeLikeLiteral(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "%", `\%`)
return strings.ReplaceAll(s, "_", `\_`)
}
// bodyIndexPredicate asserts the raw body text holds the literals in order. ILike renders as
// LOWER(body) LIKE LOWER(?) on the ClickHouse flavor — the expression both bloom filters index.
// They are plain text and backslash-free by construction, so only the LIKE wildcards need escaping.
func bodyIndexPredicate(literals []string, sb *sqlbuilder.SelectBuilder) string {
if len(literals) == 0 {
return ""
}
escaped := make([]string, 0, len(literals))
for _, literal := range literals {
escaped = append(escaped, escapeLikeLiteral(literal))
}
pattern := "%" + strings.Join(escaped, "%") + "%"
return sb.ILike(LogsV2BodyColumn, pattern)
}
func getBodyJSONPath(key *telemetrytypes.TelemetryFieldKey) string {
parts := strings.Split(key.Name, ".")
newParts := []string{}
@@ -249,14 +139,14 @@ func GetBodyJSONKeyForExists(_ context.Context, key *telemetrytypes.TelemetryFie
return fmt.Sprintf("JSON_EXISTS(body, '$.%s')", getBodyJSONPath(key))
}
// legacyElemType infers the has-family element type from the arg (legacy has no schema). It
// scans EVERY value so the chosen array type and all coerced args agree — else ClickHouse
// legacyElemType infers the has-family element type from the needle (legacy has no schema). It
// scans EVERY value so the chosen array type and all coerced needles agree — else ClickHouse
// raises "no supertype ... String" (code 386). Int64 stays distinct from Float64 so a quoted
// integer is exact past 2^53 (unquoted literals already arrive as float64, parsed upstream).
func legacyElemType(arg any) telemetrytypes.FieldDataType {
list, ok := arg.([]any)
func legacyElemType(needle any) telemetrytypes.FieldDataType {
list, ok := needle.([]any)
if !ok {
list = []any{arg}
list = []any{needle}
}
if len(list) == 0 {
return telemetrytypes.FieldDataTypeString
@@ -277,7 +167,7 @@ func legacyElemType(arg any) telemetrytypes.FieldDataType {
}
default:
// booleans (and anything else) -> String; a bool renders to 'true'/'false', so a
// bool arg only matches genuine JSON booleans, not truthy numbers/strings.
// bool needle only matches genuine JSON booleans, not truthy numbers/strings.
allInt, allNumeric = false, false
}
}
@@ -291,9 +181,9 @@ func legacyElemType(arg any) telemetrytypes.FieldDataType {
}
}
// legacyCoerceElement coerces an element to elem type dt so its bound-arg type matches the
// legacyCoerceNeedle coerces a needle to elem type dt so its bound-arg type matches the
// extracted column (legacyElemType guarantees it's coercible).
func legacyCoerceElement(v any, dt telemetrytypes.FieldDataType) any {
func legacyCoerceNeedle(v any, dt telemetrytypes.FieldDataType) any {
switch dt {
case telemetrytypes.FieldDataTypeInt64:
if s, ok := v.(string); ok {
@@ -309,7 +199,7 @@ func legacyCoerceElement(v any, dt telemetrytypes.FieldDataType) any {
}
return v
default:
return bodyArrayElementString(v)
return bodyArrayNeedleString(v)
}
}
@@ -326,8 +216,8 @@ func getBodyJSONArrayKey(key *telemetrytypes.TelemetryFieldKey, dt telemetrytype
// getBodyJSONScalarKey builds the single-element-set fallback for a scalar body value: the leaf
// extracted as a scalar of type dt, plus a guard restricting it to a genuinely scalar body. The
// guard is required because JSON_VALUE returns for an array/object/missing value, which would
// otherwise zero-value match (has(x,0) / has(x,false) / has(x,) on any array). ok=false when
// guard is required because JSON_VALUE returns '' for an array/object/missing value, which would
// otherwise zero-value match (has(x,0) / has(x,false) / has(x,'') on any array). ok=false when
// the path still traverses an array ([*]/[]).
func getBodyJSONScalarKey(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) (expr string, guard string, ok bool) {
name := strings.TrimSuffix(strings.TrimSuffix(key.Name, "[*]"), "[]")
@@ -352,7 +242,7 @@ func getBodyJSONScalarKey(key *telemetrytypes.TelemetryFieldKey, dt telemetrytyp
return expr, guard, true
}
func bodyArrayElementString(v any) string {
func bodyArrayNeedleString(v any) string {
switch t := v.(type) {
case string:
return t

View File

@@ -391,6 +391,96 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
}
}
// TestConditionForScopeIntrinsicFields covers the scope.name/scope.version intrinsic
// fields against the "scope" JSON column. These are *declared* String paths on that
// column, so a row without a scope reads as ” and never NULL: presence must be an
// empty-string check, since "IS NOT NULL" would hold for every row. That also rules
// out treating them as nested attribute keys under scope.attributes, which are
// undeclared (Dynamic) paths and genuinely NULL when absent.
func TestConditionForScopeIntrinsicFields(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
conditionBuilder := NewConditionBuilder(fm, flaggertest.New(t))
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
operator qbtypes.FilterOperator
value any
expectedSQL string
}{
{
name: "Equal - scope.name",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorEqual,
value: "io.signoz.payment",
expectedSQL: "(scope.name::String = ? AND scope.name::String <> '')",
},
{
name: "Equal - scope.version",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorEqual,
value: "2.3.1",
expectedSQL: "(scope.version::String = ? AND scope.version::String <> '')",
},
{
name: "Exists - scope.name",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorExists,
value: nil,
expectedSQL: "scope.name::String <> ''",
},
{
name: "NotExists - scope.version",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorNotExists,
value: nil,
expectedSQL: "scope.version::String = ''",
},
{
// `scope.attribute.name` (normalized to {attribute.name, scope}) addresses the scope
// attribute named `name` — the declared `scope.name` path is never reached this way.
name: "Equal - scope.attribute.name reaches the named scope attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "attribute.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
operator: qbtypes.FilterOperatorEqual,
value: "io.signoz.checkout",
expectedSQL: "(scope.attributes.`name`::String = ? AND scope.attributes.`name` IS NOT NULL)",
},
}
for _, tc := range testCases {
sb := sqlbuilder.NewSelectBuilder()
t.Run(tc.name, func(t *testing.T) {
conds, _, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, map[string][]*telemetrytypes.TelemetryFieldKey{tc.key.Name: {&tc.key}}, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
require.NoError(t, err)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expectedSQL)
assert.NotContains(t, sql, "scope.`scope.", "must not double-prefix the scope JSON path")
})
}
}
// TestConditionForSynthesizedKeys covers the KeyNotFound fallback: when a
// referenced attribute key has no metadata match, the builder synthesizes key(s) from
// user input and queries anyway, emitting a warning instead of failing.
@@ -414,6 +504,17 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
assert.Contains(t, args, "timeout")
})
t.Run("scope context with no metadata -> scope attribute", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "custom.attr", FieldContext: telemetrytypes.FieldContextScope}
conds, warnings, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, &key, noMatches, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "v", sb)
assert.NoError(t, err, "an undeclared scope attribute must still be filterable")
assert.NotEmpty(t, warnings)
sb.Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, "scope.attributes.`custom.attr`")
})
t.Run("bare key with number operand -> attribute number", func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
key := telemetrytypes.TelemetryFieldKey{Name: "http.status"}

View File

@@ -121,6 +121,20 @@ var (
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.name": {
Name: "scope.name",
Description: "Instrumentation scope name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
"scope.version": {
Name: "scope.version",
Description: "Instrumentation scope version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
"traceID": {

View File

@@ -53,6 +53,7 @@ var (
ValueType: schema.ColumnTypeString,
}},
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
"events": {Name: "events", Type: schema.ArrayColumnType{
ElementType: schema.ColumnTypeString,
@@ -181,7 +182,7 @@ func (m *fieldMapper) getColumn(
case telemetrytypes.FieldContextResource:
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{}, qbtypes.ErrColumnNotFound
return []*schema.Column{indexV3Columns["scope"]}, nil
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
@@ -292,14 +293,25 @@ func (m *fieldMapper) resolveColumnExprs(
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// json is only supported for resource context as of now
if key.FieldContext != telemetrytypes.FieldContextResource {
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
}
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
// once clickHouse dependency is updated, we need to check if we can remove it.
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
case telemetrytypes.FieldContextScope:
if f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
// declared String paths on the scope column read '' for the missing case
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s <> ''", key.Name))
} else {
attributeName := strings.TrimPrefix(key.Name, "attribute.") // literal "attribute" prefix in attribute keys needs double prefix
exprs = append(exprs, fmt.Sprintf("%s.attributes.%s::String", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.%s IS NOT NULL", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
}
default:
return nil, nil, nil, errors.NewInternalf(errors.CodeInternal, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
}
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,
@@ -341,20 +353,6 @@ 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
@@ -419,14 +417,12 @@ func (m *fieldMapper) ColumnExpressionFor(
var candidates []*telemetrytypes.LogicalField
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
case err == nil:
// 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)}
// Every match from metadata is kept, similar to the filter path.
candidates = querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
if len(candidates) == 0 {
candidates = []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(field.Name, field)}
}
case errors.Is(err, qbtypes.ErrColumnNotFound):
// 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))...)
@@ -599,11 +595,37 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
// strict context honored as-is: stripped interpretation first, literal spelling second
literal := telemetrytypes.NewTelemetryFieldKey(field.FieldContext.StringValue()+"."+field.Name, field.FieldContext, field.FieldDataType)
return append(querybuilder.SynthesizeKeys(field, value), querybuilder.SynthesizeKeys(literal, value)...)
case telemetrytypes.FieldContextScope:
// Declared scope paths (scope.name / scope.version) arrive via metadata as intrinsics;
// anything reaching synth is an undeclared scope attribute.
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
}
// contexts that don't exist on spans (log, body, scope, …) have nothing to synthesize
// contexts that don't exist on spans (log, body, …) have nothing to synthesize
return nil
}
// scopeJSONExistsExpression renders the existence predicate for the scope JSON column, the one
// signal-specific case the generic querybuilder.ExistsExpression must not carry.
func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpression string, exists bool) (string, bool) {
if key.FieldContext != telemetrytypes.FieldContextScope {
return "", false
}
// Declared String paths are non-Nullable (absent reads '' not NULL).
if f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
if exists {
return fieldExpression + " <> ''", true
}
return fieldExpression + " = ''", true
}
// Scope attribute: the value expression casts the JSON path to String, which folds a missing
// key's NULL to '', so presence must test the raw path — drop the ::String cast.
path := strings.TrimSuffix(fieldExpression, "::String")
if exists {
return path + " IS NOT NULL", true
}
return path + " IS NULL", true
}
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
func (m *fieldMapper) ExistsFor(
ctx context.Context,
@@ -620,5 +642,8 @@ func (m *fieldMapper) ExistsFor(
if err != nil {
return "", err
}
if expr, ok := scopeJSONExistsExpression(key, fieldExpression, exists); ok {
return expr, nil
}
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
}

View File

@@ -84,6 +84,45 @@ func TestGetFieldKeyName(t *testing.T) {
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
expectedError: nil,
},
{
name: "Scope field - scope.name",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.name::String",
expectedError: nil,
},
{
name: "Scope field - scope.version",
key: telemetrytypes.TelemetryFieldKey{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.version::String",
expectedError: nil,
},
{
name: "Scope field - custom attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "custom.attr",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.attributes.`custom.attr`::String",
expectedError: nil,
},
{
// `scope.attribute.name` normalizes to {attribute.name, scope}; the literal
// `attribute.` prefix is dropped so it addresses the scope attribute named `name`
// (which the declared `scope.name` path deliberately does not).
name: "Scope field - attribute prefix addresses the named scope attribute",
key: telemetrytypes.TelemetryFieldKey{
Name: "attribute.name",
FieldContext: telemetrytypes.FieldContextScope,
},
expectedResult: "scope.attributes.`name`::String",
expectedError: nil,
},
{
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",
@@ -304,3 +343,99 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
assert.Contains(t, result, "attributes_number['timestamp']")
})
}
// TestColumnExpressionForScopeDeclaredPath covers select-side resolution of scope names that
// collide with a declared scope path. A short name under scope context (or the bare
// `scope.<x>` spelling that normalizes to it) names both homes and coalesces them when
// metadata knows a same-named scope attribute, and binds to the declared path alone when it
// does not. The full `scope.<x>` name under explicit scope context addresses the declared
// path alone; the explicit `scope.attribute.` prefix addresses the attribute alone.
func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
ctx := context.Background()
scopeKey := func(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
}
declaredOnly := map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {scopeKey("scope.name")},
"scope.version": {scopeKey("scope.version")},
}
withAttr := map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {scopeKey("scope.name")},
"scope.version": {scopeKey("scope.version")},
"name": {scopeKey("name")},
"version": {scopeKey("version")},
}
testCases := []struct {
name string
key telemetrytypes.TelemetryFieldKey
keys map[string][]*telemetrytypes.TelemetryFieldKey
expectedResult string
}{
{
name: "short name under scope context binds to the declared path",
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
keys: declaredOnly,
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
},
{
name: "full scope.version name under scope context addresses the declared path alone",
key: telemetrytypes.TelemetryFieldKey{Name: "scope.version", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
},
{
name: "full scope.name name under scope context addresses the declared path alone",
key: telemetrytypes.TelemetryFieldKey{Name: "scope.name", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.name::String <> '', scope.name::String, NULL)",
},
{
// `scope.attribute.name` normalizes to {attribute.name, scope}; the `attribute.`
// prefix is dropped so it addresses the scope attribute named `name` — the only
// way to reach it, since `scope.name` is reserved for the declared path.
name: "attribute prefix reaches the named scope attribute",
key: telemetrytypes.TelemetryFieldKey{Name: "attribute.name", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, NULL)",
},
{
// the caller supplied the context, so `scope.` is part of the name rather than a
// prefix to strip: this addresses a scope attribute literally named
// `scope.testing.env`, not the attribute `testing.env`
name: "explicit context keeps a scope-prefixed name intact",
key: telemetrytypes.TelemetryFieldKey{Name: "scope.testing.env", FieldContext: telemetrytypes.FieldContextScope},
keys: declaredOnly,
expectedResult: "multiIf(scope.attributes.`scope.testing.env` IS NOT NULL, scope.attributes.`scope.testing.env`::String, NULL)",
},
{
// metadata knows both homes under this name, so the short spelling coalesces
// them instead of being rejected as ambiguous
name: "short name coalesces a known scope attribute with the declared path",
key: telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, toString(scope.attributes.`name`::String), scope.name::String <> '', toString(scope.name::String), NULL)",
},
{
name: "short version coalesces a known scope attribute with the declared path",
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
keys: withAttr,
expectedResult: "multiIf(scope.attributes.`version` IS NOT NULL, toString(scope.attributes.`version`::String), scope.version::String <> '', toString(scope.version::String), NULL)",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fm := NewFieldMapper(flaggertest.New(t))
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
require.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
})
}
}

View File

@@ -113,6 +113,20 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
FieldDataType: telemetrytypes.FieldDataTypeBool,
},
},
"scope.name": {
{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"scope.version": {
{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
// both spellings of an enabled semantic-convention family
"deployment.environment.name": {
{

View File

@@ -18,7 +18,7 @@ import (
// - Use `scope.` prefix to explicitly indicate and enforce scope context. Example
// - `scope.name`
// - `scope.version`
// - `scope.my.custom.attribute` and `scope.attribute.my.custom.attribute` resolve to same attribute
// - `scope.my.custom.attribute` resolves to the `my.custom.attribute` scope attribute
//
// - Use `attribute.` to explicitly indicate and enforce attribute context. Example
// - `attribute.http.method`
@@ -190,7 +190,7 @@ func (FieldContext) Enum() []any {
FieldContextSpan,
FieldContextTrace,
FieldContextResource,
// FieldContextScope,
FieldContextScope,
FieldContextAttribute,
// FieldContextEvent,
FieldContextBody,

View File

@@ -294,6 +294,17 @@ func TestNormalize(t *testing.T) {
FieldDataType: FieldDataTypeString,
},
},
{
name: "Normalize keeps a prefix that does not match the set context",
input: TelemetryFieldKey{
Name: "scope.name",
FieldContext: FieldContextAttribute,
},
expected: TelemetryFieldKey{
Name: "scope.name",
FieldContext: FieldContextAttribute,
},
},
{
name: "Normalize body field",
input: TelemetryFieldKey{

View File

@@ -253,27 +253,6 @@ def get_preview_sql(response: requests.Response, name: str) -> str:
return statements[0]["db.statement.query"]
def get_preview_skip_indexes(response: requests.Response, name: str) -> dict[str, dict[str, Any]]:
"""The skip-index steps of the named query's read funnel, keyed by index name.
Needs a verbose preview. ClickHouse lists a skip index only when the predicate matches its
expression, so an absent entry means it was never consulted."""
statements = get_preview_statements(response, name)
assert len(statements) == 1, f"expected 1 statement for query {name}, got {len(statements)}"
granules = statements[0]["granules"]
assert granules is not None, f"query {name} reads no MergeTree table: {statements[0]}"
return {step["name"]: step for read in granules["reads"] for step in read["steps"] if step["type"] == "Skip"}
def get_preview_selected_granules(response: requests.Response, name: str) -> int:
"""Granules surviving every index step of the named query's read funnel."""
statements = get_preview_statements(response, name)
assert len(statements) == 1, f"expected 1 statement for query {name}, got {len(statements)}"
granules = statements[0]["granules"]
assert granules is not None, f"query {name} reads no MergeTree table: {statements[0]}"
return granules["selected"]
def aligned_epoch(ago: timedelta, step_seconds: int = DEFAULT_STEP_INTERVAL) -> int:
"""Epoch seconds for `now - ago`, floored to a step boundary so seeded
points land exactly on the query's toStartOfInterval buckets."""
@@ -1020,6 +999,8 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"cloud.provider": "integration",
"cloud.account.id": "000",
"trace_id": "corrupt_data",
"scope_name": "corrupt_data",
"scope.scope.name": "corrupt_data",
},
attributes={
"net.transport": "IP.TCP",
@@ -1028,7 +1009,10 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"http.request.method": "POST",
"http.response.status_code": "200",
"timestamp": "corrupt_data",
"version": "1.0.0",
"scope.scope.version": "1.0.0",
},
scope={"name": "io.signoz.http.server", "version": "2.0.0"},
),
Traces(
timestamp=now - timedelta(seconds=3.5),
@@ -1048,12 +1032,24 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"cloud.provider": "integration",
"cloud.account.id": "000",
"timestamp": "corrupt_data",
"scope.attributes.name": "corrupt_data",
},
attributes={
"db.name": "integration",
"db.operation": "SELECT",
"db.statement": "SELECT * FROM integration",
"trace_d": "corrupt_data",
"scope.attributes.version": "corrupt_data",
},
scope={
"name": "io.opentelemetry.contrib.http",
"version": "1.0.0",
"attributes": {
"telemetry.sdk.language": "cpp",
"name": "not-the-real-name",
"version": "not-the-real-version",
"attributes": "literally-a-key-named-attributes",
},
},
),
Traces(
@@ -1074,12 +1070,15 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"cloud.provider": "integration",
"cloud.account.id": "000",
"duration_nano": "corrupt_data",
"scope.scope.attributes.version": "corrupt_data",
},
attributes={
"http.request.method": "PATCH",
"http.status_code": "404",
"id": "1",
"scope.scope.version": "corrupt_data",
},
scope={"name": "io.signoz.http.client", "version": "2.0.0"},
),
Traces(
timestamp=now - timedelta(seconds=1),
@@ -1098,6 +1097,7 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
"scope.scope.version": "corrupt_data",
},
attributes={
"message.type": "SENT",
@@ -1105,7 +1105,10 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
"messaging.message.id": "001",
"duration_nano": "corrupt_data",
"id": 1,
"scope": "corrupt_data",
"scope.attributes.name": "corrupt_data",
},
scope={"name": "io.signoz.messaging", "version": "3.0.0"},
),
]

View File

@@ -302,6 +302,7 @@ class Traces(ABC):
db_operation: str
has_error: bool
is_remote: str
scope_json: dict[str, Any]
resource: list[TracesResource]
tag_attributes: list[TracesTagAttributes]
@@ -327,6 +328,7 @@ class Traces(ABC):
links: list[TracesLink] = [],
trace_state: str = "",
flags: np.uint32 = 0,
scope: dict[str, Any] = {},
resource_write_mode: Literal["legacy_only", "dual_write"] = "dual_write",
) -> None:
if timestamp is None:
@@ -408,6 +410,33 @@ class Traces(ABC):
# Calculate resource fingerprint
self.resource_fingerprint = LogsOrTracesFingerprint(self.resources_string).calculate()
# Process scope mirroring the InstrumentationScope on the OTLP span.
scope_name = scope.get("name", "")
scope_version = scope.get("version", "")
scope_string = {k: str(v) for k, v in scope.get("attributes", {}).items()}
self.scope_json = {
"name": scope_name,
"version": scope_version,
"attributes": scope_string,
}
scope_keys = {"scope.name": scope_name, "scope.version": scope_version}
scope_keys.update(scope_string)
for k, v in scope_keys.items():
if v == "":
continue
self.tag_attributes.append(
TracesTagAttributes(
timestamp=timestamp,
tag_key=k,
tag_type="scope",
tag_data_type="string",
string_value=v,
number_value=None,
)
)
self.attribute_keys.append(TracesResourceOrAttributeKeys(name=k, datatype="string", tag_type="scope"))
# Process attributes by type and populate custom fields
self.attribute_string = {}
self.attributes_number = {}
@@ -659,6 +688,7 @@ class Traces(ABC):
self.has_error,
self.is_remote,
self.resource_json,
self.scope_json,
],
dtype=object,
)
@@ -689,6 +719,7 @@ class Traces(ABC):
attributes=data.get("attributes", {}),
trace_state=data.get("trace_state", ""),
flags=data.get("flags", 0),
scope=data.get("scope", {}),
)
@classmethod
@@ -828,6 +859,7 @@ def insert_traces_to_clickhouse(conn, traces: list[Traces]) -> None:
"has_error",
"is_remote",
"resource",
"scope",
],
data=[trace.np_arr() for trace in traces],
)

View File

@@ -1,90 +0,0 @@
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.logs import Logs
from fixtures.querier import build_order_by, build_raw_query, get_rows, make_query_request
LOWER = "alpha"
UPPER = "ALPHA"
PLAIN = "beta"
NON_ASCII = "Mixed CASE Ünïcode"
SLASH = "GET /api/v1/users"
SUPERSTRING = "GET /api/v1/users/42"
QUOTE = 'say "hi" now'
BACKSLASH = "C:\\tmp\\log"
LIKE_META = "100% _off"
TAB = "tab\there"
CTRL = "ctrl\x01here"
BODIES = [LOWER, UPPER, PLAIN, NON_ASCII, SLASH, SUPERSTRING, QUOTE, BACKSLASH, LIKE_META, TAB, CTRL]
# querierlogs/16_body_equality.py with use_json_body on: `body` resolves to body_v2.message,
# which the lower(body) companion skips, and the same expressions must still answer alike.
@pytest.mark.parametrize(
"expression,expected_bodies",
[
pytest.param(f"body = '{LOWER}'", {LOWER}, id="equality_exact"),
pytest.param(f"body = '{UPPER}'", {UPPER}, id="equality_other_case"),
pytest.param("body = 'Alpha'", set(), id="equality_case_must_match"),
pytest.param(f"body = '{NON_ASCII}'", {NON_ASCII}, id="equality_non_ascii"),
pytest.param("body = 'gamma'", set(), id="equality_no_match"),
pytest.param(f"body = '{SLASH}'", {SLASH}, id="equality_slash"),
pytest.param("body = 'say \"hi\" now'", {QUOTE}, id="equality_quote"),
pytest.param(r"body = 'C:\\tmp\\log'", {BACKSLASH}, id="equality_backslash"),
pytest.param(f"body = '{LIKE_META}'", {LIKE_META}, id="equality_like_metacharacters"),
pytest.param("body = 'tab\there'", {TAB}, id="equality_tab"),
pytest.param("body = 'ctrl\x01here'", {CTRL}, id="equality_control_char"),
pytest.param("body = 'GET /api/v1'", set(), id="equality_prefix_does_not_match"),
pytest.param(f"body IN ('{LOWER}', '{PLAIN}')", {LOWER, PLAIN}, id="in_excludes_other_case"),
pytest.param(f"body IN ('{SLASH}', '{LIKE_META}')", {SLASH, LIKE_META}, id="in_escaped_values"),
pytest.param(f"body NOT IN ('{LOWER}', '{UPPER}')", set(BODIES) - {LOWER, UPPER}, id="not_in"),
],
)
def test_logs_body_equality_json(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected_bodies: set[str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
body=body,
)
for i, body in enumerate(BODIES)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
# body_v2 comes back parsed; a plain-string body is {"message": <body>}.
assert {row["data"]["body"]["message"] for row in get_rows(response)} == expected_bodies

View File

@@ -1,92 +0,0 @@
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.logs import Logs
from fixtures.querier import build_order_by, build_raw_query, get_column_data_from_response, make_query_request
LOWER = "alpha"
UPPER = "ALPHA"
PLAIN = "beta"
NON_ASCII = "Mixed CASE Ünïcode"
SLASH = "GET /api/v1/users"
SUPERSTRING = "GET /api/v1/users/42"
QUOTE = 'say "hi" now'
BACKSLASH = "C:\\tmp\\log"
LIKE_META = "100% _off"
TAB = "tab\there"
CTRL = "ctrl\x01here"
BODIES = [LOWER, UPPER, PLAIN, NON_ASCII, SLASH, SUPERSTRING, QUOTE, BACKSLASH, LIKE_META, TAB, CTRL]
# `body = ?` carries a case-insensitive LOWER(body) companion for the bloom filters, so a
# body differing only in case must still not come back.
@pytest.mark.parametrize(
"expression,expected_bodies",
[
pytest.param(f"body = '{LOWER}'", {LOWER}, id="equality_exact"),
pytest.param(f"body = '{UPPER}'", {UPPER}, id="equality_other_case"),
pytest.param("body = 'Alpha'", set(), id="equality_case_must_match"),
pytest.param(f"body = '{NON_ASCII}'", {NON_ASCII}, id="equality_non_ascii"),
pytest.param("body = ''", set(), id="equality_empty"),
pytest.param("body = 'gamma'", set(), id="equality_no_match"),
# the companion is a LIKE-free equality, so none of these are metacharacters to it
pytest.param(f"body = '{SLASH}'", {SLASH}, id="equality_slash"),
pytest.param("body = 'say \"hi\" now'", {QUOTE}, id="equality_quote"),
pytest.param(r"body = 'C:\\tmp\\log'", {BACKSLASH}, id="equality_backslash"),
pytest.param(f"body = '{LIKE_META}'", {LIKE_META}, id="equality_like_metacharacters"),
pytest.param("body = 'tab\there'", {TAB}, id="equality_tab"),
pytest.param("body = 'ctrl\x01here'", {CTRL}, id="equality_control_char"),
# a prefix of another body must not match it
pytest.param("body = 'GET /api/v1'", set(), id="equality_prefix_does_not_match"),
pytest.param(f"body IN ('{LOWER}', '{PLAIN}')", {LOWER, PLAIN}, id="in_excludes_other_case"),
pytest.param(f"body IN ('{SLASH}', '{LIKE_META}')", {SLASH, LIKE_META}, id="in_escaped_values"),
pytest.param(f"body NOT IN ('{LOWER}', '{UPPER}')", set(BODIES) - {LOWER, UPPER}, id="not_in"),
],
)
def test_logs_body_equality(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected_bodies: set[str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
body=body,
)
for i, body in enumerate(BODIES)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
assert set(get_column_data_from_response(response.json(), "body")) == expected_bodies

View File

@@ -1,346 +0,0 @@
import json
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.logs import Logs
from fixtures.querier import (
build_order_by,
build_raw_query,
get_preview_selected_granules,
get_preview_skip_indexes,
get_rows,
make_preview_query_request,
make_query_request,
)
# The legacy body holds the text the producer wrote, so the same value reaches ClickHouse under
# different encodings: PHP escapes `/`, Go escapes `&` `<` `>`, Python escapes non-ASCII. The
# LOWER(body) predicates the filters carry for the bloom filters must find all of them.
BODIES = {
"plain": '{"tag":"plain","url":"https://signoz.io/docs","user_id":4242,"status":"timeout_error"}',
"php": '{"tag":"php","url":"https:\\/\\/signoz.io\\/docs"}',
"go": '{"tag":"go","note":"connection reset \\u0026 retry aborted"}',
"python": '{"tag":"python","city":"caf\\u00e9 municipal district"}',
"other_case": '{"tag":"other_case","status":"TIMEOUT_ERROR"}',
"no_user_id": '{"tag":"no_user_id","status":"ok","url":"https://signoz.io/pricing"}',
"tagged": '{"tag":"tagged","labels":["production","webserver"]}',
"tagged_escaped": '{"tag":"tagged_escaped","labels":["batch \\u0026 stream","webserver"]}',
}
@pytest.mark.parametrize(
"expression,expected_tags",
[
pytest.param("body.user_id = 4242", {"plain"}, id="numeric_equality"),
pytest.param("body.user_id EXISTS", {"plain"}, id="exists"),
# a negated comparison matches the rows without the path, so it carries no predicate
pytest.param("body.user_id != 4242", set(BODIES) - {"plain"}, id="not_equal_keeps_pathless_rows"),
pytest.param("body.status NOT EXISTS", {"php", "go", "python", "tagged", "tagged_escaped"}, id="not_exists"),
pytest.param("body.url = 'https://signoz.io/docs'", {"plain", "php"}, id="equality_escaped_slashes"),
pytest.param("body.url CONTAINS 'signoz.io/docs'", {"plain", "php"}, id="contains_escaped_slashes"),
pytest.param("body.note = 'connection reset & retry aborted'", {"go"}, id="equality_escaped_ampersand"),
pytest.param("body.city = 'café municipal district'", {"python"}, id="equality_escaped_non_ascii"),
# the value predicate is case-insensitive where the equality is not
pytest.param("body.status = 'timeout_error'", {"plain"}, id="equality_underscore"),
pytest.param("body.status = 'TIMEOUT_ERROR'", {"other_case"}, id="equality_other_case"),
pytest.param("body.status IN ('timeout_error', 'ok')", {"plain", "no_user_id"}, id="in_carries_one_value_per_arm"),
# has and hasAll assert every element, hasAny only one of them
pytest.param("has(body.labels[*], 'production')", {"tagged"}, id="has_element"),
pytest.param("has(body.labels[*], 'batch & stream')", {"tagged_escaped"}, id="has_escaped_element"),
pytest.param("hasAll(body.labels[*], ['production', 'webserver'])", {"tagged"}, id="has_all_needs_every_element"),
pytest.param(
"hasAny(body.labels[*], ['production', 'batch & stream'])",
{"tagged", "tagged_escaped"},
id="has_any_needs_one_element",
),
# 'webserver' is in both, so an ORed literal set must not exclude either row
pytest.param(
"hasAny(body.labels[*], ['webserver', 'nothing here'])",
{"tagged", "tagged_escaped"},
id="has_any_across_both",
),
],
)
def test_logs_body_json_index_predicates(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected_tags: set[str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
body=body,
)
for i, body in enumerate(BODIES.values())
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
assert {json.loads(row["data"]["body"])["tag"] for row in get_rows(response)} == expected_tags
# JSON_VALUE matches no index expression, so the literals are what get the bloom filters consulted
# at all; the read funnel is what catches one that stops matching.
BODY_BLOOM_FILTERS = {"body_index_v2_token", "body_index_v2_ngram"}
@pytest.mark.parametrize(
"expression,prunes_every_granule",
[
pytest.param("body.status = 'timeout_error'", False, id="value_needle_present"),
pytest.param("body.status = 'zz_no_seeded_body_holds_this'", True, id="value_needle_absent"),
pytest.param("body.zz_no_seeded_body_holds_this EXISTS", True, id="path_needle_absent"),
# a number is compared after JSONExtract parses it, so it carries no value literal - only
# its path, which every row holding the key satisfies
pytest.param("body.user_id = 999999", False, id="number_carries_only_its_path"),
],
)
def test_logs_body_json_index_prunes_granules(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
prunes_every_granule: bool,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
body=body,
)
for i, body in enumerate(BODIES.values())
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_preview_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
skip_indexes = get_preview_skip_indexes(response, "A")
assert BODY_BLOOM_FILTERS <= set(skip_indexes), f"body bloom filters not consulted, only: {sorted(skip_indexes)}"
selected = get_preview_selected_granules(response, "A")
if prunes_every_granule:
assert selected == 0, f"expected every granule pruned, {selected} survived"
else:
assert selected > 0, "the granule holding the match must survive"
# `body = ?` matches no index expression on its own; the lowered companion is what the filters
# prune on, so its absence from the funnel is the regression this catches.
@pytest.mark.parametrize(
"expression,prunes_every_granule",
[
pytest.param("body = 'alpha'", False, id="equality_present_value"),
pytest.param("body = 'zz_no_seeded_body_holds_this'", True, id="equality_absent_value"),
# IN delegates to the equalities, so every arm carries its own companion
pytest.param("body IN ('alpha', 'beta')", False, id="in_present_values"),
pytest.param("body IN ('zz_absent_one', 'zz_absent_two')", True, id="in_absent_values"),
],
)
def test_logs_body_equality_prunes_granules(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
prunes_every_granule: bool,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "api"}, body=body) for i, body in enumerate(["alpha", "ALPHA", "beta"])])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_preview_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
skip_indexes = get_preview_skip_indexes(response, "A")
assert BODY_BLOOM_FILTERS <= set(skip_indexes), f"body bloom filters not consulted, only: {sorted(skip_indexes)}"
selected = get_preview_selected_granules(response, "A")
if prunes_every_granule:
assert selected == 0, f"expected every granule pruned, {selected} survived"
else:
assert selected > 0, "the granule holding the match must survive"
# The attribute maps carry a bloom filter over mapValues, which the subscript the comparison reads
# matches no more than the body column did. mapContains prunes on the key alone, so these use a key
# every row carries to isolate what the value predicate contributes.
ATTRIBUTE_NUMBER_VALUE_INDEX = "attributes_number_idx_val"
ATTRIBUTE_STRING_VALUE_INDEX = "attributes_string_idx_val"
@pytest.mark.parametrize(
"expression,prunes_every_granule",
[
pytest.param("attribute.resp_code = 503", False, id="value_present"),
pytest.param("attribute.resp_code = 60599", True, id="value_absent"),
# IN delegates to the equalities, so every arm carries its own membership assertion
pytest.param("attribute.resp_code IN (503, 200)", False, id="in_present_values"),
pytest.param("attribute.resp_code IN (60599, 60600)", True, id="in_absent_values"),
],
)
def test_attribute_number_equality_prunes_granules(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
prunes_every_granule: bool,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "api"}, attributes={"resp_code": code}) for i, code in enumerate([200, 200, 503])])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_preview_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
skip_indexes = get_preview_skip_indexes(response, "A")
assert ATTRIBUTE_NUMBER_VALUE_INDEX in skip_indexes, f"mapValues filter not consulted, only: {sorted(skip_indexes)}"
selected = get_preview_selected_granules(response, "A")
if prunes_every_granule:
assert selected == 0, f"expected every granule pruned, {selected} survived"
else:
assert selected > 0, "the granule holding the match must survive"
# The mapValues filter indexes the values raw, so a case-insensitive match reaches it only for a
# pattern holding no ASCII letter, where LOWER changes nothing. A letter leaves it unconsulted.
@pytest.mark.parametrize(
"expression,value_index_consulted,prunes_every_granule",
[
pytest.param("attribute.client.ip CONTAINS '192.168.77'", True, False, id="letter_free_value_present"),
pytest.param("attribute.client.ip CONTAINS '192.168.99'", True, True, id="letter_free_value_absent"),
pytest.param("attribute.env CONTAINS 'production'", False, False, id="letters_leave_it_unconsulted"),
# the filter is consulted for any letter-free pattern, but a run below the index ngram
# leaves it nothing to check
pytest.param("attribute.client.ip CONTAINS '.7'", True, False, id="run_shorter_than_the_ngram"),
],
)
def test_attribute_letter_free_match_prunes_granules(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
value_index_consulted: bool,
prunes_every_granule: bool,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
attributes={"client.ip": ip, "env": "production"},
)
for i, ip in enumerate(["10.0.0.1", "10.0.0.2", "192.168.77.31"])
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_preview_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc"), build_order_by("id", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
skip_indexes = get_preview_skip_indexes(response, "A")
assert (ATTRIBUTE_STRING_VALUE_INDEX in skip_indexes) == value_index_consulted, f"consulted: {sorted(skip_indexes)}"
selected = get_preview_selected_granules(response, "A")
if prunes_every_granule:
assert selected == 0, f"expected every granule pruned, {selected} survived"
else:
assert selected > 0, "the granule holding the match must survive"

View File

@@ -1240,6 +1240,13 @@ def test_traces_list_span_scope(
lambda x: {"duration_nano": int(x[1].duration_nano), "span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
id="select_attribute_duration_order_intrinsic",
),
# Case 9: filter on the intrinsic scope.version. Only x[1] should match.
pytest.param(
BuilderQuery(signal="traces", name="A", select_fields=[TelemetryFieldKey("timestamp")], filter_expression="scope.version = '1.0.0'", limit=1),
HTTPStatus.OK,
lambda x: {"span_id": x[1].span_id, "timestamp": format_timestamp(x[1].timestamp), "trace_id": x[1].trace_id},
id="filter_scope_version",
),
],
)
def test_traces_list_with_corrupt_data(
@@ -1283,6 +1290,168 @@ def test_traces_list_with_corrupt_data(
assert get_rows(response)[0]["data"] == expected(traces)
@pytest.mark.parametrize(
"filter_expression,expected_indices",
[
# Intrinsic scope.name / scope.version resolve to the JSON sub-columns.
pytest.param("scope.name = 'io.signoz.payment'", [1], id="intrinsic_scope_name"),
pytest.param("scope.version = '2.3.1'", [0], id="intrinsic_scope_version"),
# A scope attribute resolves against the scope JSON column's attributes.
pytest.param("scope.telemetry.sdk.language = 'python'", [1], id="scope_attribute"),
# A scope attribute whose own name carries a `scope.` prefix. `scope.prefixed`
# normalizes to {prefixed, scope} and must still resolve to the attribute.
pytest.param("scope.prefixed = 'prefixed-val'", [0], id="scope_prefixed_attribute"),
# `env.tier` is a span attribute on span 0 and a scope attribute on
# span 1. Unprefixed -> no explicit context, so it is checked in every
# applicable context (attribute OR scope) and both spans match.
pytest.param("env.tier = 'gold'", [0, 1], id="bare_cross_context"),
# The explicit `scope.` prefix forces scope context only, so span 0's
# span attribute is ignored — only span 1 matches.
pytest.param("scope.env.tier = 'gold'", [1], id="scope_prefixed_cross_context"),
# `scope.name` names both homes it can resolve to: the declared scope.name field
# (span 0) and a same-named `name` scope attribute (span 1), the same way any
# other name colliding across contexts unions. `scope.attribute.name` addresses
# the attribute alone.
pytest.param("scope.name = 'io.signoz.checkout'", [0, 1], id="scope_name_unions_attribute"),
# The `scope.name` spelling is also a real stored key: span 2 carries a span
# attribute literally named `scope.name`, so it matches too.
pytest.param("scope.name = 'attr-scope-name'", [2], id="scope_name_matches_stored_spelling"),
# The explicit `scope.attribute.` prefix addresses the scope attribute alone, without
# the declared path. Span 1 has a `name` scope attribute = 'io.signoz.checkout'.
pytest.param("scope.attribute.name = 'io.signoz.checkout'", [1], id="scope_attribute_name"),
# `version` as a scope attribute: no span carries one (span 1's 4.5.6 is the declared
# scope.version, not a scope attribute), so this matches nothing.
pytest.param("scope.attribute.version = '4.5.6'", [], id="scope_attribute_version_none"),
# An unprefixed `name` is checked in every applicable context: the span `name`
# column (span 2) and a `name` scope attribute (span 1). It does not reach the
# declared scope.name field (span 0), which only the `scope.` prefix addresses.
pytest.param("name = 'io.signoz.checkout'", [1, 2], id="bare_name_unions_scope_attribute"),
# A value that no resolvable key holds (scope.name/scope.version field,
# a `name`/`version` scope attribute, or a same-named attribute/resource)
# returns nothing.
pytest.param("scope.version = 'corrupt_data'", [], id="scope_version_no_match"),
pytest.param("scope.name = 'corrupt_data'", [], id="scope_name_no_match"),
],
)
def test_traces_list_with_scope_filter(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
filter_expression: str,
expected_indices: list[int],
) -> None:
"""
Setup three spans with different scope key resolution:
- x[0]: scope.name/version 'io.signoz.checkout'/'2.3.1'; span attribute
env.tier='gold'.
- x[1]: scope.name/version 'io.signoz.payment'/'4.5.6'; scope attributes
telemetry.sdk.language='python', env.tier='gold', and a `name` scope
attribute colliding with x[0]'s scope.name value.
- x[2]: span name 'io.signoz.checkout' (colliding with x[0]'s scope.name
value) and a span attribute literally named `scope.name`.
Tests:
- Filtering on scope.name / scope.version / a scope attribute.
- An unprefixed key is resolved across contexts (scope checked alongside
attribute / intrinsic), while a `scope.`-prefixed key is scope-only.
- `scope.name`/`scope.version` name every home they resolve to: the declared JSON
sub-column and a same-named `name`/`version` scope attribute. The explicit
`scope.attribute.` prefix addresses the attribute alone.
- a bare `name` reaches the span `name` column and a `name` scope attribute, but
never the declared scope.name field.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
span_ids = [TraceIdGenerator.span_id() for _ in range(3)]
traces = [
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=2),
trace_id=trace_id,
span_id=span_ids[0],
parent_span_id="",
name="GET /checkout",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "checkout"},
attributes={"http.request.method": "GET", "env.tier": "gold"},
scope={
"name": "io.signoz.checkout",
"version": "2.3.1",
# a scope attribute whose own name carries a `scope.` prefix
"attributes": {"telemetry.sdk.language": "go", "scope.prefixed": "prefixed-val"},
},
),
Traces(
timestamp=now - timedelta(seconds=2),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=span_ids[1],
parent_span_id="",
name="POST /pay",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "payment"},
attributes={"http.request.method": "POST"},
# env.tier is a scope attribute here (cross-context with span 0);
# `name` is a scope attribute colliding with span 0's scope.name.
scope={
"name": "io.signoz.payment",
"version": "4.5.6",
"attributes": {
"telemetry.sdk.language": "python",
"env.tier": "gold",
"name": "io.signoz.checkout",
},
},
),
Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=span_ids[2],
parent_span_id="",
# span name collides with span 0's scope.name value
name="io.signoz.checkout",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "probe"},
# a span attribute named `scope.name`
attributes={"scope.name": "attr-scope-name"},
scope={"name": "span-gamma", "version": "9.9.9"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=1)).timestamp() * 1000)
end_ms = int((now + timedelta(seconds=1)).timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="traces",
name="A",
select_fields=[TelemetryFieldKey("timestamp")],
filter_expression=filter_expression,
limit=10,
).to_dict()
],
)
assert response.status_code == HTTPStatus.OK, response.text
got_span_ids = {row["data"]["span_id"] for row in get_rows(response)}
expected_span_ids = {traces[i].span_id for i in expected_indices}
assert got_span_ids == expected_span_ids
@pytest.mark.parametrize("surface", ["filter", "select", "order"])
def test_traces_list_unknown_span_context_synthesizes(
signoz: types.SigNoz,