mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-19 19:30:40 +01:00
Compare commits
2 Commits
ns/scope-q
...
fix/panel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1face0e22 | ||
|
|
19c722044a |
@@ -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()}`);
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -79,7 +79,6 @@ export function useCreateAlertFromPanel(): (
|
||||
const unit = readPanelUnit(panel.spec.plugin);
|
||||
const url = buildAlertUrl(
|
||||
query,
|
||||
panelType,
|
||||
unit,
|
||||
deriveAlertPrefill(panel, query, unit),
|
||||
);
|
||||
|
||||
@@ -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()));
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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) > ?")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user