diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index 00df6f1f5cd..b4615b13265 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -8801,6 +8801,7 @@ components: - span - trace - resource + - scope - attribute - body - "" @@ -15468,10 +15469,8 @@ paths: $ref: '#/components/schemas/RenderErrorResponse' description: Internal Server Error security: - - api_key: - - VIEWER - - tokenizer: - - VIEWER + - api_key: [] + - tokenizer: [] summary: Get features tags: - features diff --git a/ee/query-service/app/api/api.go b/ee/query-service/app/api/api.go index 27e5826a6c1..87079173589 100644 --- a/ee/query-service/app/api/api.go +++ b/ee/query-service/app/api/api.go @@ -67,7 +67,7 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { // note: add ee override methods first // routes available only in ee version - router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet) // base overrides router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet) diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 8643ce4eb52..2a80cf0f3f7 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -3492,6 +3492,7 @@ export enum TelemetrytypesFieldContextDTO { span = 'span', trace = 'trace', resource = 'resource', + scope = 'scope', attribute = 'attribute', body = 'body', '' = '', diff --git a/frontend/src/container/AlertHistory/Timeline/Table/utils.ts b/frontend/src/container/AlertHistory/Timeline/Table/utils.ts index 349a205df60..ef1d113c2c1 100644 --- a/frontend/src/container/AlertHistory/Timeline/Table/utils.ts +++ b/frontend/src/container/AlertHistory/Timeline/Table/utils.ts @@ -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, diff --git a/frontend/src/container/LiveLogs/LiveLogsList/LiveLogsList.styles.scss b/frontend/src/container/LiveLogs/LiveLogsList/LiveLogsList.styles.scss index 7b343ce0502..613f5c47ade 100644 --- a/frontend/src/container/LiveLogs/LiveLogsList/LiveLogsList.styles.scss +++ b/frontend/src/container/LiveLogs/LiveLogsList/LiveLogsList.styles.scss @@ -4,13 +4,6 @@ padding: 0px 8px; .logs-frequency-chart { - .ant-card-body { - height: 140px; - min-height: 140px; - padding: 0 16px 22px 16px; - font-family: 'Geist Mono'; - } - margin-bottom: 0px; } } diff --git a/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styles.scss b/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styles.scss index e0e46e13958..10b0becfff7 100644 --- a/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styles.scss +++ b/frontend/src/container/LogsExplorerChart/LogsExplorerChart.styles.scss @@ -3,13 +3,6 @@ min-height: 200px; border-bottom: 1px solid var(--l1-border); - .ant-card-body { - height: 200px; - min-height: 200px; - padding: 0 16px 16px 16px; - font-family: 'Geist Mono'; - } - .logs-frequency-chart-loading { height: 100%; display: flex; diff --git a/frontend/src/container/LogsExplorerChart/index.tsx b/frontend/src/container/LogsExplorerChart/index.tsx index 14b4e8ed3da..756ae2be205 100644 --- a/frontend/src/container/LogsExplorerChart/index.tsx +++ b/frontend/src/container/LogsExplorerChart/index.tsx @@ -1,25 +1,29 @@ -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo, useRef } from 'react'; // eslint-disable-next-line no-restricted-imports import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import Graph from 'components/Graph'; import Spinner from 'components/Spinner'; import { QueryParams } from 'constants/query'; -import { themeColors } from 'constants/theme'; +import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart'; +import { useResizeObserver } from 'hooks/useDimensions'; import { useSafeNavigate } from 'hooks/useSafeNavigate'; import useUrlQuery from 'hooks/useUrlQuery'; -import getChartData, { GetChartDataProps } from 'lib/getChartData'; import GetMinMax from 'lib/getMinMax'; -import { colors } from 'lib/getRandomColor'; +import { LegendPosition } from 'lib/uPlotV2/components/types'; +import { StackMode } from 'lib/uPlotV2/config/types'; +import { useTimezone } from 'providers/Timezone'; import { UpdateTimeInterval } from 'store/actions'; import { AppState } from 'store/reducers'; import { GlobalReducer } from 'types/reducer/globalTime'; import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces'; -import { getColorsForSeverityLabels } from './utils'; +import { useLogsExplorerChartConfig } from './useLogsExplorerChartConfig'; import './LogsExplorerChart.styles.scss'; +// Axis and tooltip format separately; both need this or only one abbreviates. +const Y_AXIS_UNIT = 'short'; + function LogsExplorerChart({ data, isLoading, @@ -37,24 +41,6 @@ function LogsExplorerChart({ const { minTime, maxTime } = useSelector( (state) => state.globalTime, ); - const handleCreateDatasets: Required['createDataset'] = - useCallback( - (element, index, allLabels) => ({ - data: element, - backgroundColor: isLogsExplorerViews - ? getColorsForSeverityLabels(allLabels[index], index) - : colors[index % colors.length] || themeColors.red, - borderColor: isLogsExplorerViews - ? getColorsForSeverityLabels(allLabels[index], index) - : colors[index % colors.length] || themeColors.red, - ...(isLabelEnabled - ? { - label: allLabels[index], - } - : {}), - }), - [isLabelEnabled, isLogsExplorerViews], - ); const onDragSelect = useCallback( (start: number, end: number): void => { @@ -86,44 +72,47 @@ function LogsExplorerChart({ [dispatch, location.pathname, safeNavigate, urlQuery, isShowingLiveLogs], ); - const graphData = useMemo( - () => - getChartData({ - queryData: [ - { - queryData: data, - }, - ], - createDataset: handleCreateDatasets, - }), - [data, handleCreateDatasets], - ); - - // Convert nanosecond timestamps to milliseconds for Chart.js - const { chartMinTime, chartMaxTime } = useMemo( + // uPlot plots the series on a seconds-based x scale + const { minTimeScale, maxTimeScale } = useMemo( () => ({ - chartMinTime: minTime ? Math.floor(minTime / 1e6) : undefined, - chartMaxTime: maxTime ? Math.floor(maxTime / 1e6) : undefined, + minTimeScale: minTime ? Math.floor(minTime / 1e9) : undefined, + maxTimeScale: maxTime ? Math.floor(maxTime / 1e9) : undefined, }), [minTime, maxTime], ); + const { timezone } = useTimezone(); + const graphRef = useRef(null); + const dimensions = useResizeObserver(graphRef); + + const { config, chartData } = useLogsExplorerChartConfig({ + data, + isLogsExplorerViews, + isLabelEnabled, + onDragSelect, + minTimeScale, + maxTimeScale, + yAxisUnit: Y_AXIS_UNIT, + }); + return ( -
+
{isLoading ? (
) : ( - )}
diff --git a/frontend/src/container/LogsExplorerChart/useLogsExplorerChartConfig.ts b/frontend/src/container/LogsExplorerChart/useLogsExplorerChartConfig.ts new file mode 100644 index 00000000000..3ecb6b3a4da --- /dev/null +++ b/frontend/src/container/LogsExplorerChart/useLogsExplorerChartConfig.ts @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { themeColors } from 'constants/theme'; +import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder'; +import { useIsDarkMode } from 'hooks/useDarkMode'; +import getLabelName from 'lib/getLabelName'; +import { colors } from 'lib/getRandomColor'; +import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData'; +import { DrawStyle } from 'lib/uPlotV2/config/types'; +import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder'; +import { useTimezone } from 'providers/Timezone'; +import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; +import { QueryData } from 'types/api/widgets/getQuery'; +import uPlot from 'uplot'; + +import { getColorsForSeverityLabels } from './utils'; + +export interface UseLogsExplorerChartConfigParams { + data: QueryData[]; + isLogsExplorerViews?: boolean; + isLabelEnabled?: boolean; + onDragSelect: (start: number, end: number) => void; + minTimeScale?: number; + maxTimeScale?: number; + yAxisUnit?: string; +} + +export interface UseLogsExplorerChartConfigResult { + config: UPlotConfigBuilder; + chartData: uPlot.AlignedData; +} + +export function useLogsExplorerChartConfig({ + data, + isLogsExplorerViews = false, + isLabelEnabled = true, + onDragSelect, + minTimeScale, + maxTimeScale, + yAxisUnit, +}: UseLogsExplorerChartConfigParams): UseLogsExplorerChartConfigResult { + const isDarkMode = useIsDarkMode(); + const { timezone } = useTimezone(); + + // getUPlotChartData / buildBaseConfig both consume the legacy query-range payload + // shape, so the raw series list is wrapped instead of being plotted directly. + const apiResponse = useMemo( + () => + ({ + data: { result: data, resultType: '' }, + }) as unknown as MetricRangePayloadProps, + [data], + ); + + const chartData = useMemo(() => getUPlotChartData(apiResponse), [apiResponse]); + + const config = useMemo(() => { + const builder = buildBaseConfig({ + id: 'logs-explorer-frequency-chart', + isDarkMode, + onDragSelect, + timezone, + minTimeScale, + maxTimeScale, + yAxisUnit, + panelType: PANEL_TYPES.BAR, + }); + + data.forEach((series, index) => { + const label = getLabelName( + series.metric, + series.queryName || '', + series.legend || '', + ); + + const color = isLogsExplorerViews + ? getColorsForSeverityLabels(label, index) + : colors[index % colors.length] || themeColors.red; + + builder.addSeries({ + scaleKey: 'y', + drawStyle: DrawStyle.Bar, + // No group-by yields query name "A"; use ' ' not '' so uPlot does not default the label to "Value". + label: isLabelEnabled && label.trim() ? label : ' ', + lineColor: color, + colorMapping: {}, + isDarkMode, + }); + }); + + return builder; + }, [ + data, + isDarkMode, + isLabelEnabled, + isLogsExplorerViews, + maxTimeScale, + minTimeScale, + onDragSelect, + timezone, + yAxisUnit, + ]); + + return { config, chartData }; +} diff --git a/frontend/src/container/LogsExplorerViews/LogsExplorerViews.styles.scss b/frontend/src/container/LogsExplorerViews/LogsExplorerViews.styles.scss index 68af75fc9e2..b0a3347b6cc 100644 --- a/frontend/src/container/LogsExplorerViews/LogsExplorerViews.styles.scss +++ b/frontend/src/container/LogsExplorerViews/LogsExplorerViews.styles.scss @@ -217,13 +217,6 @@ padding: 0px 8px; .logs-frequency-chart { - .ant-card-body { - height: 140px; - min-height: 140px; - padding: 0 16px 22px 16px; - font-family: 'Geist Mono'; - } - margin-bottom: 0px; } } diff --git a/pkg/apiserver/signozapiserver/flagger.go b/pkg/apiserver/signozapiserver/flagger.go index f1c31e99825..f01be6cdad5 100644 --- a/pkg/apiserver/signozapiserver/flagger.go +++ b/pkg/apiserver/signozapiserver/flagger.go @@ -4,13 +4,12 @@ import ( "net/http" "github.com/SigNoz/signoz/pkg/http/handler" - "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/types/featuretypes" "github.com/gorilla/mux" ) func (provider *provider) addFlaggerRoutes(router *mux.Router) error { - if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.ViewAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{ + if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.OpenAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{ ID: "GetFeatures", Tags: []string{"features"}, Summary: "Get features", @@ -22,7 +21,7 @@ func (provider *provider) addFlaggerRoutes(router *mux.Router) error { SuccessStatusCode: http.StatusOK, ErrorStatusCodes: []int{}, Deprecated: false, - SecuritySchemes: newSecuritySchemes(types.RoleViewer), + SecuritySchemes: newScopedSecuritySchemes(nil), })).Methods(http.MethodGet).GetError(); err != nil { return err } diff --git a/pkg/query-service/app/http_handler.go b/pkg/query-service/app/http_handler.go index 995cfa3bb92..ddf71110c2d 100644 --- a/pkg/query-service/app/http_handler.go +++ b/pkg/query-service/app/http_handler.go @@ -439,7 +439,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) { router.HandleFunc("/api/v2/traces/fields", am.EditAccess(aH.updateTraceField)).Methods(http.MethodPost) router.HandleFunc("/api/v1/version", am.OpenAccess(aH.getVersion)).Methods(http.MethodGet) - router.HandleFunc("/api/v1/features", am.ViewAccess(aH.getFeatureFlags)).Methods(http.MethodGet) + router.HandleFunc("/api/v1/features", am.OpenAccess(aH.getFeatureFlags)).Methods(http.MethodGet) router.HandleFunc("/api/v1/health", am.OpenAccess(aH.getHealth)).Methods(http.MethodGet) router.HandleFunc("/api/v1/listErrors", am.ViewAccess(aH.listErrors)).Methods(http.MethodPost) @@ -1497,7 +1497,7 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) { claims, err := authtypes.ClaimsFromContext(r.Context()) if err != nil { - aH.HandleError(w, err, http.StatusInternalServerError) + aH.HandleError(w, err, http.StatusUnauthorized) return } diff --git a/pkg/querybuilder/key_resolution.go b/pkg/querybuilder/key_resolution.go index 554043385d4..026f3c439a9 100644 --- a/pkg/querybuilder/key_resolution.go +++ b/pkg/querybuilder/key_resolution.go @@ -107,8 +107,8 @@ func SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telem fieldContext = telemetrytypes.FieldContextAttribute } fieldDataType := field.FieldDataType - // Resource values are strings; pin the type so operand coercion applies. - if fieldContext == telemetrytypes.FieldContextResource && + // Resource and scope values are strings; pin the type so operand coercion applies. + if (fieldContext == telemetrytypes.FieldContextResource || fieldContext == telemetrytypes.FieldContextScope) && fieldDataType == telemetrytypes.FieldDataTypeUnspecified { fieldDataType = telemetrytypes.FieldDataTypeString } diff --git a/pkg/querybuilder/query_to_keys.go b/pkg/querybuilder/query_to_keys.go index 620c8c5f1d7..2a1bebd795f 100644 --- a/pkg/querybuilder/query_to_keys.go +++ b/pkg/querybuilder/query_to_keys.go @@ -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, + }) + } } } diff --git a/pkg/querybuilder/query_to_keys_test.go b/pkg/querybuilder/query_to_keys_test.go index 358c662bea9..81767e3c19c 100644 --- a/pkg/querybuilder/query_to_keys_test.go +++ b/pkg/querybuilder/query_to_keys_test.go @@ -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 { diff --git a/pkg/statementbuilder/tracesstatementbuilder/statement_builder.go b/pkg/statementbuilder/tracesstatementbuilder/statement_builder.go index 3299ef5ae32..c4168ed707c 100644 --- a/pkg/statementbuilder/tracesstatementbuilder/statement_builder.go +++ b/pkg/statementbuilder/tracesstatementbuilder/statement_builder.go @@ -204,31 +204,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 { @@ -239,6 +223,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: @@ -310,20 +314,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 { @@ -335,6 +333,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, diff --git a/pkg/statementbuilder/tracesstatementbuilder/stmt_builder_test.go b/pkg/statementbuilder/tracesstatementbuilder/stmt_builder_test.go index 8bd7485950a..eb6c28c4ca6 100644 --- a/pkg/statementbuilder/tracesstatementbuilder/stmt_builder_test.go +++ b/pkg/statementbuilder/tracesstatementbuilder/stmt_builder_test.go @@ -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 { diff --git a/pkg/telemetrymetadata/metadata.go b/pkg/telemetrymetadata/metadata.go index 68db7b0b40b..d745dfdf7a6 100644 --- a/pkg/telemetrymetadata/metadata.go +++ b/pkg/telemetrymetadata/metadata.go @@ -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`, diff --git a/pkg/telemetryschema/tracestelemetryschema/condition_builder_test.go b/pkg/telemetryschema/tracestelemetryschema/condition_builder_test.go index ec0e4c1ec2c..b442dc5931a 100644 --- a/pkg/telemetryschema/tracestelemetryschema/condition_builder_test.go +++ b/pkg/telemetryschema/tracestelemetryschema/condition_builder_test.go @@ -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,20 @@ 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`") + // `scope.` can be part of the attribute's own name, so the literal spelling is a + // candidate too — the caller ORs the two. + assert.Contains(t, sql, "scope.attributes.`scope.custom.attr`") + }) + t.Run("bare key with number operand -> attribute number", func(t *testing.T) { sb := sqlbuilder.NewSelectBuilder() key := telemetrytypes.TelemetryFieldKey{Name: "http.status"} diff --git a/pkg/telemetryschema/tracestelemetryschema/const.go b/pkg/telemetryschema/tracestelemetryschema/const.go index ac46ca434d3..c9b0607a7b8 100644 --- a/pkg/telemetryschema/tracestelemetryschema/const.go +++ b/pkg/telemetryschema/tracestelemetryschema/const.go @@ -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": { diff --git a/pkg/telemetryschema/tracestelemetryschema/field_mapper.go b/pkg/telemetryschema/tracestelemetryschema/field_mapper.go index c4883a6619e..72d3b84b0a6 100644 --- a/pkg/telemetryschema/tracestelemetryschema/field_mapper.go +++ b/pkg/telemetryschema/tracestelemetryschema/field_mapper.go @@ -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,9 +417,11 @@ 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 @@ -595,15 +595,37 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t // honored as-is: the stripped name lives in the attribute maps stripped := telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextUnspecified, field.FieldDataType) return querybuilder.SynthesizeKeys(stripped, value) - case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource: + case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope: // 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)...) } - // 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) } diff --git a/pkg/telemetryschema/tracestelemetryschema/field_mapper_test.go b/pkg/telemetryschema/tracestelemetryschema/field_mapper_test.go index 6a242f0ed13..8198674d8f7 100644 --- a/pkg/telemetryschema/tracestelemetryschema/field_mapper_test.go +++ b/pkg/telemetryschema/tracestelemetryschema/field_mapper_test.go @@ -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.` 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.` 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) + }) + } +} diff --git a/pkg/telemetryschema/tracestelemetryschema/test_data.go b/pkg/telemetryschema/tracestelemetryschema/test_data.go index 5ce145c94e2..c86bc9231f8 100644 --- a/pkg/telemetryschema/tracestelemetryschema/test_data.go +++ b/pkg/telemetryschema/tracestelemetryschema/test_data.go @@ -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": { { diff --git a/pkg/types/telemetrytypes/field_context.go b/pkg/types/telemetrytypes/field_context.go index c5536bbb281..f98c01e0f11 100644 --- a/pkg/types/telemetrytypes/field_context.go +++ b/pkg/types/telemetrytypes/field_context.go @@ -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, diff --git a/pkg/types/telemetrytypes/field_test.go b/pkg/types/telemetrytypes/field_test.go index c7094da2a5b..b7ef14a9bc5 100644 --- a/pkg/types/telemetrytypes/field_test.go +++ b/pkg/types/telemetrytypes/field_test.go @@ -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{ diff --git a/tests/fixtures/querier.py b/tests/fixtures/querier.py index d24ccea1ae2..077481fcc9c 100644 --- a/tests/fixtures/querier.py +++ b/tests/fixtures/querier.py @@ -999,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", @@ -1007,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), @@ -1027,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( @@ -1053,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), @@ -1077,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", @@ -1084,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"}, ), ] diff --git a/tests/fixtures/traces.py b/tests/fixtures/traces.py index 0572ab4e591..b85d163a8b4 100644 --- a/tests/fixtures/traces.py +++ b/tests/fixtures/traces.py @@ -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 @@ def __init__( 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 @@ def __init__( # 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 @@ def np_arr(self) -> np.array: self.has_error, self.is_remote, self.resource_json, + self.scope_json, ], dtype=object, ) @@ -689,6 +719,7 @@ def from_dict( 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], ) diff --git a/tests/integration/tests/queriertraces/01_list.py b/tests/integration/tests/queriertraces/01_list.py index fa501bd670d..848e1fa2f55 100644 --- a/tests/integration/tests/queriertraces/01_list.py +++ b/tests/integration/tests/queriertraces/01_list.py @@ -1240,6 +1240,13 @@ def query_scope(expression: str) -> list[str]: 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,