Compare commits

..

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
f1face0e22 fix(alerts): stop writing a panelTypes URL param nothing reads
FormAlertRules stopped reading panelTypes off the URL in #5251, which pinned
the alert panel type to graph. The alert URL builders kept setting it, so a
plain string param with no consumer rode along into whatever page came next.
2026-08-19 13:57:05 +05:30
Ashwin Bhatkal
19c722044a fix(explorer): accept a plain string panelTypes URL param
The alerts flow writes panelTypes as a plain string while the explorers
write it JSON encoded, so JSON.parse threw on the raw value and took the
Logs and Traces explorers down through the error boundary.
2026-08-19 12:12:41 +05:30
23 changed files with 124 additions and 599 deletions

View File

@@ -139,11 +139,6 @@ export default function AlertRules({
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, record.id);
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);

View File

@@ -26,7 +26,7 @@ describe('ListAlertRules — row click navigation', () => {
const [url] = safeNavigateMock.mock.calls[0];
expect(url).toContain('/alerts/overview?');
expect(url).toContain('ruleId=rule-1');
expect(url).toContain('panelTypes=graph');
expect(url).not.toContain('panelTypes');
expect(url).toContain('compositeQuery=');
});

View File

@@ -36,11 +36,6 @@ export function useAlertRulesHandlers(
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, rule.id);
return `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;

View File

@@ -95,7 +95,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -0,0 +1,54 @@
import { Router } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { createMemoryHistory } from 'history';
import { useGetPanelTypesQueryParam } from './useGetPanelTypesQueryParam';
const renderWithSearch = (
search: string,
defaultPanelType?: PANEL_TYPES,
): PANEL_TYPES | null => {
const history = createMemoryHistory({
initialEntries: [`/logs/logs-explorer${search}`],
});
const { result } = renderHook(
() => useGetPanelTypesQueryParam(defaultPanelType),
{
wrapper: ({ children }) => <Router history={history}>{children}</Router>,
},
);
return result.current;
};
describe('useGetPanelTypesQueryParam', () => {
it('reads a JSON encoded panel type, as written by the explorers', () => {
expect(renderWithSearch('?panelTypes=%22table%22', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.TABLE,
);
});
it('reads a plain string panel type, as written by the alerts flow', () => {
expect(renderWithSearch('?panelTypes=graph', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.TIME_SERIES,
);
});
it('falls back to the default for an unparseable panel type', () => {
expect(renderWithSearch('?panelTypes=%7Bfoo', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.LIST,
);
});
it('falls back to the default for a value that is not a panel type', () => {
expect(renderWithSearch('?panelTypes=%22nope%22', PANEL_TYPES.LIST)).toBe(
PANEL_TYPES.LIST,
);
});
it('falls back to the default when the param is absent', () => {
expect(renderWithSearch('', PANEL_TYPES.LIST)).toBe(PANEL_TYPES.LIST);
});
});

View File

@@ -3,6 +3,24 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
const PANEL_TYPE_VALUES = new Set<string>(Object.values(PANEL_TYPES));
// The param is JSON encoded by the explorers and written as a plain string by the
// alerts flow, so accept both and treat anything unrecognised as absent.
const parsePanelType = (value: string): PANEL_TYPES | null => {
let parsed: unknown = value;
try {
parsed = JSON.parse(value);
} catch {
parsed = value;
}
return typeof parsed === 'string' && PANEL_TYPE_VALUES.has(parsed)
? (parsed as PANEL_TYPES)
: null;
};
export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
defaultPanelType?: T,
): T extends undefined ? PANEL_TYPES | null : PANEL_TYPES => {
@@ -11,6 +29,10 @@ export const useGetPanelTypesQueryParam = <T extends PANEL_TYPES | undefined>(
return useMemo(() => {
const panelTypeQuery = urlQuery.get(QueryParams.panelTypes);
return panelTypeQuery ? JSON.parse(panelTypeQuery) : defaultPanelType;
}, [urlQuery, defaultPanelType]);
return (
(panelTypeQuery ? parsePanelType(panelTypeQuery) : null) ?? defaultPanelType
);
}, [urlQuery, defaultPanelType]) as T extends undefined
? PANEL_TYPES | null
: PANEL_TYPES;
};

View File

@@ -168,7 +168,6 @@ describe('useCreateAlertFromPanel', () => {
// The resolved query is seeded with the panel-derived alert prefill.
expect(mockBuildAlertUrl).toHaveBeenCalledWith(
{ resolved: 'query' },
PANEL_TYPES.TIME_SERIES,
undefined,
mockPrefill,
);

View File

@@ -79,7 +79,6 @@ export function useCreateAlertFromPanel(): (
const unit = readPanelUnit(panel.spec.plugin);
const url = buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);

View File

@@ -65,14 +65,19 @@ describe('buildCreateAlertUrl', () => {
);
});
it('tags the URL with panel type, v5 version, and the dashboards source', () => {
it('tags the URL with the v5 version and the dashboards source', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBe(PANEL_TYPES.TIME_SERIES);
expect(params.get(QueryParams.version)).toBe(ENTITY_VERSION_V5);
expect(params.get(QueryParams.source)).toBe('dashboards');
});
it('does not tag the URL with a panel type, which the alert page ignores', () => {
const params = parse(buildCreateAlertUrl(makePanel()));
expect(params.get(QueryParams.panelTypes)).toBeNull();
});
it('encodes the translated query as the compositeQuery param', () => {
const params = parse(buildCreateAlertUrl(makePanel()));

View File

@@ -5,7 +5,6 @@ import type {
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
@@ -34,7 +33,6 @@ export function readPanelUnit(
*/
export function buildAlertUrl(
query: Query,
panelType: PANEL_TYPES,
unit?: string,
prefill?: PanelAlertPrefill,
): string {
@@ -48,7 +46,6 @@ export function buildAlertUrl(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
params.set(QueryParams.panelTypes, panelType);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);
@@ -76,10 +73,5 @@ export function buildCreateAlertUrl(panel: DashboardtypesPanelDTO): string {
const panelType = PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind];
const query = fromPerses(panel.spec.queries, panelType);
const unit = readPanelUnit(panel.spec.plugin);
return buildAlertUrl(
query,
panelType,
unit,
deriveAlertPrefill(panel, query, unit),
);
return buildAlertUrl(query, unit, deriveAlertPrefill(panel, query, unit));
}

View File

@@ -147,11 +147,6 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
// So we can safely override the context and data type
actions = append(actions, fmt.Sprintf("Overriding key: %s to %s", key, intrinsicOrCalculatedField))
// Adopt the canonical name of the field it resolved to, the same way the metadata
// path below does. This is a no-op when the caller looked the field up by the key's
// own name, and carries the qualified name for fields registered under one
// (`name` with scope context -> `scope.name`).
key.Name = intrinsicOrCalculatedField.Name
key.OverrideMetadataFrom(intrinsicOrCalculatedField)
return actions

View File

@@ -56,21 +56,6 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
FieldDataType: key.FieldDataType,
})
}
// A scope attribute whose flattened name begins with `scope.` (e.g. an OTel
// scope attribute nested under `scope`) is indistinguishable from the `scope.`
// context prefix after Normalize strips it. Also fetch the metadata key under
// its full `scope.`-prefixed name so resolution can find it.
// 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,26 +72,6 @@ func TestQueryToKeys(t *testing.T) {
},
},
},
{
// A scope reference also fetches its full `scope.`-prefixed name so a scope
// attribute whose flattened name begins with `scope.` (e.g. `scope.prefixed`)
// is discoverable after Normalize strips the prefix.
query: `scope.prefixed = 'local'`,
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

@@ -259,23 +259,6 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
return actions
}
// intrinsicLookupName returns the name under which a key is registered in the intrinsic and
// calculated field tables. Span-context intrinsics are registered bare (`name`, `duration_nano`)
// while scope intrinsics are registered fully qualified (`scope.name`, `scope.version`), so a
// scope key must be looked up qualified — bare lookup would match the span intrinsic of the same
// name. A scope key that misses stays qualified rather than falling back to the bare name: a
// scope attribute named `duration_nano` is not the span `duration_nano` column.
func intrinsicLookupName(key *telemetrytypes.TelemetryFieldKey) string {
if key.FieldContext != telemetrytypes.FieldContextScope {
return key.Name
}
prefix := telemetrytypes.FieldContextScope.StringValue() + "."
if strings.HasPrefix(key.Name, prefix) {
return key.Name
}
return prefix + key.Name
}
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
@@ -286,22 +269,20 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
For example: trace_id (intrinsic), response_status_code (calculated).
*/
lookupName := intrinsicLookupName(key)
var isIntrinsicOrCalculatedField bool
var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey
if _, ok := tracestelemetryschema.IntrinsicFields[lookupName]; ok {
if _, ok := tracestelemetryschema.IntrinsicFields[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFields[lookupName]
} else if _, ok := tracestelemetryschema.CalculatedFields[lookupName]; ok {
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFields[key.Name]
} else if _, ok := tracestelemetryschema.CalculatedFields[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFields[lookupName]
} else if _, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[lookupName]; ok {
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFields[key.Name]
} else if _, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFieldsDeprecated[lookupName]
} else if _, ok := tracestelemetryschema.CalculatedFieldsDeprecated[lookupName]; ok {
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]
} else if _, ok := tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]; ok {
isIntrinsicOrCalculatedField = true
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFieldsDeprecated[lookupName]
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]
}
if isIntrinsicOrCalculatedField {

View File

@@ -675,87 +675,6 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
expectedErr: nil,
},
{
name: "List query selecting and filtering scope fields",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Filter: &qbtypes.Filter{
Expression: "scope.name = 'otelcol'",
},
Limit: 10,
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "scope.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
{
Name: "telemetry.sdk.language",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
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`, multiIf(scope.attributes.`telemetry.sdk.language` IS NOT NULL, scope.attributes.`telemetry.sdk.language`::String, NULL) AS `__SELECT_KEY_4_telemetry.sdk.language` 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 <= ? LIMIT ?",
Args: []any{"otelcol", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
{
// Short scope names (`name`/`version`) collide with span intrinsics; adjustTraceKeys
// must keep them in scope and resolve the declared paths, not the span `name` column.
name: "List query selecting short scope declared names",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Limit: 10,
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
{
Name: "version",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
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`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_4_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},
},
expectedErr: nil,
},
{
// A scope attribute may share its name with a span intrinsic. It must resolve to the
// scope attribute, never to the span column of that name.
name: "List query selecting scope attribute colliding with span intrinsic",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Limit: 10,
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "duration_nano",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
},
},
},
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.`duration_nano` IS NOT NULL, scope.attributes.`duration_nano`::String, NULL) AS `__SELECT_KEY_3_duration_nano` 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},
},
expectedErr: nil,
},
}
fl := flaggertest.New(t)

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

@@ -585,97 +585,3 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
assert.NotContains(t, sql, "mapContains")
})
}
// TestConditionForScope covers filters on the scope JSON column: declared paths, scope
// attributes, exists semantics, and the attribute-first union when a scope attribute
// shares a declared path's name.
func TestConditionForScope(t *testing.T) {
ctx := context.Background()
fm := NewFieldMapper(flaggertest.New(t))
cb := NewConditionBuilder(fm, flaggertest.New(t))
scopeName := IntrinsicFields["scope.name"]
declared := map[string][]*telemetrytypes.TelemetryFieldKey{"scope.name": {&scopeName}}
build := func(key telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) (string, []any) {
t.Helper()
sb := sqlbuilder.NewSelectBuilder()
conds, _, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, &key, keys, qbtypes.ConditionBuilderOptions{}, op, value, sb)
require.NoError(t, err)
sb.Where(sb.Or(conds...))
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
t.Run("declared scope.name equality is exists-guarded", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
sql, args := build(key, declared, qbtypes.FilterOperatorEqual, "otelcol")
assert.Contains(t, sql, "scope.name::String = ?")
assert.Contains(t, sql, "scope.name::String <> ''")
assert.Contains(t, args, "otelcol")
})
t.Run("declared scope.name exists", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
sql, _ := build(key, declared, qbtypes.FilterOperatorExists, nil)
assert.Contains(t, sql, "scope.name::String <> ''")
})
t.Run("scope attribute equality guards the raw JSON path", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "telemetry.sdk.language", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"telemetry.sdk.language": {{Name: "telemetry.sdk.language", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, args := build(key, keys, qbtypes.FilterOperatorEqual, "python")
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language`::String = ?")
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language` IS NOT NULL")
assert.Contains(t, args, "python")
assert.NotContains(t, sql, "scope.`scope.")
})
t.Run("short name unions attribute and declared path", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {&scopeName},
"name": {{Name: "name", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, _ := build(key, keys, qbtypes.FilterOperatorEqual, "x")
assert.Contains(t, sql, "scope.attributes.`name`::String = ?")
assert.Contains(t, sql, "scope.name::String = ?")
})
t.Run("declared scope.version equality", func(t *testing.T) {
scopeVersion := IntrinsicFields["scope.version"]
key := telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{"scope.version": {&scopeVersion}}
sql, args := build(key, keys, qbtypes.FilterOperatorEqual, "1.2.3")
assert.Contains(t, sql, "scope.version::String = ?")
assert.Contains(t, sql, "scope.version::String <> ''")
assert.Contains(t, args, "1.2.3")
})
t.Run("negative operator on declared path does not add existence guard", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope}
sql, _ := build(key, declared, qbtypes.FilterOperatorNotEqual, "otelcol")
assert.Contains(t, sql, "scope.name::String <> ?")
assert.NotContains(t, sql, "= ''")
})
t.Run("IN on a scope attribute", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "telemetry.sdk.language", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"telemetry.sdk.language": {{Name: "telemetry.sdk.language", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, _ := build(key, keys, qbtypes.FilterOperatorIn, []any{"python", "go"})
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language`::String = ?")
assert.Contains(t, sql, "scope.attributes.`telemetry.sdk.language` IS NOT NULL")
})
t.Run("numeric operand on a scope attribute coerces the string path to float", func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "sampler.ratio", FieldContext: telemetrytypes.FieldContextScope}
keys := map[string][]*telemetrytypes.TelemetryFieldKey{
"sampler.ratio": {{Name: "sampler.ratio", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextScope, FieldDataType: telemetrytypes.FieldDataTypeString}},
}
sql, _ := build(key, keys, qbtypes.FilterOperatorGreaterThan, float64(0.5))
assert.Contains(t, sql, "toFloat64OrNull(scope.attributes.`sampler.ratio`::String) > ?")
})
}

View File

@@ -121,20 +121,6 @@ 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,7 +53,6 @@ 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,
@@ -182,7 +181,7 @@ func (m *fieldMapper) getColumn(
case telemetrytypes.FieldContextResource:
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
case telemetrytypes.FieldContextScope:
return []*schema.Column{indexV3Columns["scope"]}, nil
return []*schema.Column{}, qbtypes.ErrColumnNotFound
case telemetrytypes.FieldContextAttribute:
switch key.FieldDataType {
case telemetrytypes.FieldDataTypeString:
@@ -293,24 +292,14 @@ func (m *fieldMapper) resolveColumnExprs(
switch column.Type.GetType() {
case schema.ColumnTypeEnumJSON:
// The ::String cast is required because ClickHouse rejects Variant/Dynamic
// types in GROUP BY; revisit once the clickHouse dependency is updated.
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 isDeclaredScopePath(key.Name) {
// declared typed String paths are non-Nullable: absent reads '' not NULL.
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s::String <> ''", key.Name))
} else {
exprs = append(exprs, fmt.Sprintf("%s.attributes.`%s`::String", columnName, key.Name))
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.`%s` IS NOT NULL", columnName, key.Name))
}
default:
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
// 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))
case schema.ColumnTypeEnumString,
schema.ColumnTypeEnumUInt64,
schema.ColumnTypeEnumUInt32,
@@ -428,40 +417,23 @@ func (m *fieldMapper) ColumnExpressionFor(
// Resolve the candidate logical field(s).
var candidates []*telemetrytypes.LogicalField
switch field.FieldContext {
case telemetrytypes.FieldContextScope:
// FieldFor resolves any scope key to a single expression, so the probe below
// would skip the union. Resolve scope the way the filter path does instead:
// MatchingLogicalFields returns a same-named scope attribute (attribute-first)
// alongside the declared path, and CandidateKeys synthesizes an attribute when
// metadata knows neither.
matches := querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
candidates, _ = querybuilder.ResolveLogicalFields(field, matches)
if len(candidates) == 0 {
candidates = querybuilder.WrapAsLogicalFields(field.Name, m.CandidateKeys(ctx, orgID, field, nil, keys))
}
if len(candidates) == 0 {
return "", errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "field `%s` not found", field.Name)
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)}
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))...)
}
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
default:
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)}
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))...)
}
candidates = m.upgradeToFamilies(ctx, orgID, field, querybuilder.WrapAsLogicalFields(field.Name, raw), keys)
default:
return "", err
}
return "", err
}
// Group-by/order (String) and aggregation (String/Float64): every candidate is
@@ -512,9 +484,7 @@ func (m *fieldMapper) ColumnExpressionFor(
}
// Multiple candidates (collision / synth): multiIf picks the first that exists,
// stringified so branches share a type. Scope value expressions are already
// ::String, so they skip the redundant toString wrap.
scopeContext := field.FieldContext == telemetrytypes.FieldContextScope
// stringified so branches share a type.
args := make([]string, 0, len(candidates))
for _, logical := range candidates {
value, err := querybuilder.LogicalValueExpr(ctx, orgID, startNs, endNs, m, logical)
@@ -525,11 +495,7 @@ func (m *fieldMapper) ColumnExpressionFor(
if err != nil {
return "", err
}
if scopeContext {
args = append(args, fmt.Sprintf("%s, %s", guard, value))
} else {
args = append(args, fmt.Sprintf("%s, toString(%s)", guard, value))
}
args = append(args, fmt.Sprintf("%s, toString(%s)", guard, value))
}
return fmt.Sprintf("multiIf(%s, NULL)", strings.Join(args, ", ")), nil
}
@@ -611,13 +577,6 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
}
}
// Scope keys resolve only against the scope JSON column, so they never fall through to the
// name-only metadata match below: a same-named span or attribute entry is a different
// field (`scope.duration_nano` is not the duration_nano column).
if field.FieldContext == telemetrytypes.FieldContextScope {
return scopeCandidateKeys(field, keys)
}
// Metadata match by name, then the literal `{context}.{name}` spelling (a context can be
// a legitimate prefix in user data, e.g. `metric.max_count`). For a forgiving context
// this is the correction step (span.http.method -> attribute http.method).
@@ -641,73 +600,10 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
literal := telemetrytypes.NewTelemetryFieldKey(field.FieldContext.StringValue()+"."+field.Name, field.FieldContext, field.FieldDataType)
return append(querybuilder.SynthesizeKeys(field, value), querybuilder.SynthesizeKeys(literal, value)...)
}
// contexts that don't exist on spans (log, body, …) have nothing to synthesize
// contexts that don't exist on spans (log, body, scope, …) have nothing to synthesize
return nil
}
// scopeCandidateKeys resolves a scope-context key against the scope JSON column: a declared
// path resolves to itself even without metadata, whether referenced fully-qualified
// (`scope.name`) or by its short name (`name`); otherwise the scope-context metadata entries
// under either spelling, and failing those a synthesized scope attribute.
func scopeCandidateKeys(field *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
if isDeclaredScopePath(field.Name) {
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
}
if declaredName := telemetrytypes.FieldContextScope.StringValue() + "." + field.Name; isDeclaredScopePath(declaredName) {
return []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(declaredName, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)}
}
for _, name := range []string{field.Name, telemetrytypes.FieldContextScope.StringValue() + "." + field.Name} {
scoped := []*telemetrytypes.TelemetryFieldKey{}
for _, match := range keys[name] {
if match.FieldContext == telemetrytypes.FieldContextScope {
scoped = append(scoped, match)
}
}
if len(scoped) > 0 {
return scoped
}
}
return []*telemetrytypes.TelemetryFieldKey{synthScopeAttributeKey(field)}
}
// synthScopeAttributeKey guesses a scope attribute (scope.attributes.<name>) for a name
// absent from metadata — the scope analog of querybuilder.SynthesizeKeys.
func synthScopeAttributeKey(field *telemetrytypes.TelemetryFieldKey) *telemetrytypes.TelemetryFieldKey {
return telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextScope, telemetrytypes.FieldDataTypeString)
}
// isDeclaredScopePath reports whether name is a declared typed sub-path of the scope JSON
// column (scope.name / scope.version), as opposed to an entry in scope.attributes.
func isDeclaredScopePath(name string) bool {
f, ok := IntrinsicFields[name]
return ok && f.FieldContext == telemetrytypes.FieldContextScope
}
// scopeJSONExistsExpression renders the presence predicate for a scope JSON key, whose
// two homes differ: declared typed paths are non-Nullable (absent reads ”), while
// scope.attributes.* are Dynamic/Nullable. Returns ok=false for non-scope keys so the
// caller falls back to the generic exists expression.
func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpression string, exists bool) (string, bool) {
if key.FieldContext != telemetrytypes.FieldContextScope {
return "", false
}
if isDeclaredScopePath(key.Name) {
if exists {
return fieldExpression + " <> ''", true
}
return fieldExpression + " = ''", true
}
// The value expression casts the JSON path to String, folding 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,
@@ -724,8 +620,5 @@ 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

@@ -304,160 +304,3 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
assert.Contains(t, result, "attributes_number['timestamp']")
})
}
// scopeKey builds a TelemetryFieldKey the way the API boundary would after Normalize.
func scopeKey(name string) telemetrytypes.TelemetryFieldKey {
return telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
}
}
// declaredScopeKeys injects the scope.name/scope.version intrinsics into the metadata map
// the way metadata.go does at query time; resolution of the declared paths depends on it.
func declaredScopeKeys() map[string][]*telemetrytypes.TelemetryFieldKey {
scopeName := IntrinsicFields["scope.name"]
scopeVersion := IntrinsicFields["scope.version"]
return map[string][]*telemetrytypes.TelemetryFieldKey{
"scope.name": {&scopeName},
"scope.version": {&scopeVersion},
}
}
func scopeAttribute(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
}
// TestColumnExpressionForScope covers the scope resolution matrix from PR #10920: declared
// paths, scope attributes, and the attribute-first union when a scope attribute shares its
// name with a declared path.
func TestColumnExpressionForScope(t *testing.T) {
ctx := context.Background()
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
fm := NewFieldMapper(flaggertest.New(t))
run := func(field telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) string {
t.Helper()
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
require.NoError(t, err)
return result
}
t.Run("short name binds to declared scope.name when no attribute exists", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.name::String <> '', scope.name::String, NULL)",
run(scopeKey("name"), declaredScopeKeys()))
})
t.Run("fully-qualified scope.name isolates the declared path", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.name::String <> '', scope.name::String, NULL)",
run(scopeKey("scope.name"), declaredScopeKeys()))
})
t.Run("short version binds to declared scope.version", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.version::String <> '', scope.version::String, NULL)",
run(scopeKey("version"), declaredScopeKeys()))
})
t.Run("plain scope attribute", func(t *testing.T) {
keys := declaredScopeKeys()
keys["testing.env"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("testing.env")}
assert.Equal(t,
"multiIf(scope.attributes.`testing.env` IS NOT NULL, scope.attributes.`testing.env`::String, NULL)",
run(scopeKey("testing.env"), keys))
})
t.Run("scope attribute synthesized when absent from metadata", func(t *testing.T) {
assert.Equal(t,
"multiIf(scope.attributes.`testing.env` IS NOT NULL, scope.attributes.`testing.env`::String, NULL)",
run(scopeKey("testing.env"), declaredScopeKeys()))
})
t.Run("short name unions attribute (first) with declared path", func(t *testing.T) {
keys := declaredScopeKeys()
keys["name"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("name")}
assert.Equal(t,
"multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, scope.name::String <> '', scope.name::String, NULL)",
run(scopeKey("name"), keys))
})
t.Run("fully-qualified scope.version isolates declared even with conflicting attribute", func(t *testing.T) {
keys := declaredScopeKeys()
keys["version"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("version")}
assert.Equal(t,
"multiIf(scope.version::String <> '', scope.version::String, NULL)",
run(scopeKey("scope.version"), keys))
})
t.Run("group by short name unions attribute and declared without toString", func(t *testing.T) {
keys := declaredScopeKeys()
keys["name"] = []*telemetrytypes.TelemetryFieldKey{scopeAttribute("name")}
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, tsStart, tsEnd, &[]telemetrytypes.TelemetryFieldKey{scopeKey("name")}[0], telemetrytypes.FieldDataTypeString, keys)
require.NoError(t, err)
assert.Equal(t,
"multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, scope.name::String <> '', scope.name::String, NULL)",
result)
})
}
// TestFieldForScope covers the per-key SQL for a resolved scope key.
func TestFieldForScope(t *testing.T) {
ctx := context.Background()
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
fm := NewFieldMapper(flaggertest.New(t))
cases := map[string]string{
"scope.name": "scope.name::String",
"scope.version": "scope.version::String",
"custom.attr": "scope.attributes.`custom.attr`::String",
}
for name, want := range cases {
t.Run(name, func(t *testing.T) {
key := scopeKey(name)
got, err := fm.FieldFor(ctx, valuer.UUID{}, tsStart, tsEnd, &key)
require.NoError(t, err)
assert.Equal(t, want, got)
// A scope path must never double-prefix the JSON column.
assert.NotContains(t, got, "scope.`scope.")
})
}
}
// TestExistsForScope covers the presence predicates: declared paths test <> ” (non-Nullable),
// scope attributes test the raw JSON path IS NOT NULL.
func TestExistsForScope(t *testing.T) {
ctx := context.Background()
tsStart := uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano())
tsEnd := uint64(time.Date(2024, 6, 5, 0, 0, 0, 0, time.UTC).UnixNano())
fm := NewFieldMapper(flaggertest.New(t))
cases := []struct {
name string
key string
exists bool
want string
}{
{"declared exists", "scope.name", true, "scope.name::String <> ''"},
{"declared not exists", "scope.name", false, "scope.name::String = ''"},
{"attribute exists", "exception.type", true, "scope.attributes.`exception.type` IS NOT NULL"},
{"attribute not exists", "exception.type", false, "scope.attributes.`exception.type` IS NULL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
key := scopeKey(tc.key)
got, err := fm.ExistsFor(ctx, valuer.UUID{}, tsStart, tsEnd, &key, tc.exists)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}

View File

@@ -128,21 +128,6 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
// declared scope paths, mirroring the intrinsics metadata.go injects at query time
"scope.name": {
{
Name: "scope.name",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
"scope.version": {
{
Name: "scope.version",
FieldContext: telemetrytypes.FieldContextScope,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
for _, keys := range keysMap {
for _, key := range keys {

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` resolves to the `my.custom.attribute` scope attribute
// - `scope.my.custom.attribute` and `scope.attribute.my.custom.attribute` resolve to same 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

@@ -35,14 +35,6 @@ func TestGetFieldKeyFromKeyText(t *testing.T) {
FieldDataType: FieldDataTypeUnspecified,
},
},
{
keyText: "scope.custom.attr:string",
expected: TelemetryFieldKey{
Name: "custom.attr",
FieldContext: FieldContextScope,
FieldDataType: FieldDataTypeString,
},
},
{
keyText: "attribute.http.method",
expected: TelemetryFieldKey{