Compare commits

..

21 Commits

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

    fix: update tests

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

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

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

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

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

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

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

View File

@@ -61,7 +61,6 @@ jobs:
- querierauthz
- role
- rootuser
- savedview
- serviceaccount
- spanmapper
- querier_json_body

View File

@@ -7880,20 +7880,17 @@ components:
type: string
SavedviewtypesPostableSavedView:
properties:
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
generateName:
type: boolean
name:
type: string
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- source
- schemaVersion
- spec
- data
type: object
SavedviewtypesSavedView:
properties:
@@ -7902,16 +7899,14 @@ components:
type: string
createdBy:
type: string
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
id:
type: string
name:
type: string
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
updatedAt:
format: date-time
type: string
@@ -7919,6 +7914,14 @@ components:
type: string
required:
- id
type: object
SavedviewtypesSavedViewData:
properties:
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- schemaVersion
- spec
type: object
@@ -7933,10 +7936,7 @@ components:
queries:
items:
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
minItems: 1
type: array
requestType:
$ref: '#/components/schemas/Querybuildertypesv5RequestType'
selectedFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
@@ -7944,13 +7944,10 @@ components:
required:
- displayName
- panelType
- requestType
- queries
- selectedFields
- display
type: object
SavedviewtypesSchemaVersion:
enum:
- v2
type: string
SavedviewtypesSource:
enum:
- traces
@@ -7960,16 +7957,13 @@ components:
type: string
SavedviewtypesUpdatableSavedView:
properties:
schemaVersion:
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
data:
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
source:
$ref: '#/components/schemas/SavedviewtypesSource'
spec:
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
required:
- source
- schemaVersion
- spec
- data
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
@@ -22782,12 +22776,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:

View File

@@ -376,19 +376,7 @@ function App(): JSX.Element {
tracesSampleRate: 0, // Ref: https://github.com/SigNoz/platform-pod/issues/2393#issuecomment-4603658055
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
beforeSend(event, hint) {
const error = hint?.originalException as
| { name?: string; code?: string | number }
| undefined;
// Ignore benign aborted/cancelled requests (axios + fetch).
if (error?.code === 'ERR_CANCELED' || error?.code === 'ECONNABORTED') {
return null;
}
if (error?.name === 'AbortError') {
return null;
}
beforeSend(event) {
// Drop the event if its level is 'warning' or 'info'
if (event.level === 'warning' || event.level === 'info') {
return null;

View File

@@ -8991,17 +8991,8 @@ export enum SavedviewtypesPanelTypeDTO {
list = 'list',
trace = 'trace',
}
export enum SavedviewtypesSchemaVersionDTO {
v2 = 'v2',
}
export enum SavedviewtypesSourceDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
}
export interface SavedviewtypesSavedViewSpecDTO {
display?: SavedviewtypesDisplayDTO;
display: SavedviewtypesDisplayDTO;
/**
* @type string
*/
@@ -9011,14 +9002,28 @@ export interface SavedviewtypesSavedViewSpecDTO {
* @type array
*/
queries: Querybuildertypesv5QueryEnvelopeDTO[];
requestType: Querybuildertypesv5RequestTypeDTO;
/**
* @type array
*/
selectedFields?: TelemetrytypesTelemetryFieldKeyDTO[];
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
}
export interface SavedviewtypesSavedViewDataDTO {
/**
* @type string
*/
schemaVersion: string;
spec: SavedviewtypesSavedViewSpecDTO;
}
export enum SavedviewtypesSourceDTO {
traces = 'traces',
logs = 'logs',
metrics = 'metrics',
meter = 'meter',
}
export interface SavedviewtypesPostableSavedViewDTO {
data: SavedviewtypesSavedViewDataDTO;
/**
* @type boolean
*/
@@ -9027,9 +9032,7 @@ export interface SavedviewtypesPostableSavedViewDTO {
* @type string
*/
name?: string;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface SavedviewtypesSavedViewDTO {
@@ -9042,6 +9045,7 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
createdBy?: string;
data?: SavedviewtypesSavedViewDataDTO;
/**
* @type string
*/
@@ -9050,9 +9054,7 @@ export interface SavedviewtypesSavedViewDTO {
* @type string
*/
name?: string;
schemaVersion: SavedviewtypesSchemaVersionDTO;
source?: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
/**
* @type string
* @format date-time
@@ -9065,9 +9067,8 @@ export interface SavedviewtypesSavedViewDTO {
}
export interface SavedviewtypesUpdatableSavedViewDTO {
schemaVersion: SavedviewtypesSchemaVersionDTO;
data: SavedviewtypesSavedViewDataDTO;
source: SavedviewtypesSourceDTO;
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {

View File

@@ -0,0 +1,31 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/resetPassword';
/**
* @deprecated Use the generated `useResetPassword` hook (or `resetPassword` fetcher) from
* `api/generated/services/users` instead. This hand-written client targets the
* same endpoint and will be removed once call sites migrate.
*
* Part of https://github.com/SigNoz/engineering-pod/issues/5289, add a comment or update when removing this method.
*/
const resetPassword = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>(`/resetPassword`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default resetPassword;

View File

@@ -5,9 +5,10 @@ import {
useCreateResetPasswordToken,
useDeleteUser,
useGetResetPasswordToken,
useCreateUserRole,
useDeleteUserRole,
useGetRolesByUserID,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
} from 'api/generated/services/users';
@@ -24,14 +25,15 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useDeleteUserRole: jest.fn(),
useGetRolesByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useCreateUserRole: jest.fn(),
useSetRoleByUserID: jest.fn(),
useGetResetPasswordToken: jest.fn(),
useCreateResetPasswordToken: jest.fn(),
getGetUserQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}`,
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}/roles`,
],
}));
@@ -192,7 +194,11 @@ describe('EditMemberDrawer', () => {
isLoading: false,
refetch: jest.fn(),
});
(useDeleteUserRole as jest.Mock).mockReturnValue({
(useGetRolesByUserID as jest.Mock).mockReturnValue({
data: { data: [managedRoles[0]] },
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
isLoading: false,
});
@@ -204,7 +210,7 @@ describe('EditMemberDrawer', () => {
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
(useCreateUserRole as jest.Mock).mockReturnValue({
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
});
@@ -306,12 +312,12 @@ describe('EditMemberDrawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('adding a new role creates a user role without removing existing ones', async () => {
it('adding a new role calls setRole without removing existing ones', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useCreateUserRole as jest.Mock).mockReturnValue({
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
@@ -328,14 +334,15 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith({
data: { userId: 'user-1', roleId: managedRoles[1].id },
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
it('deselecting a role deletes the user role by its assignment id', async () => {
it('deselecting a role calls removeRole with the role id', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -354,7 +361,7 @@ describe('EditMemberDrawer', () => {
await waitFor(() => {
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'ur-1' },
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
});
expect(onComplete).toHaveBeenCalled();
});

View File

@@ -183,14 +183,15 @@ function QuerySearch({
isProgrammaticChangeRef.current = true;
}
const changes = view.state.changes({
from: 0,
to: currentValue.length,
insert: value,
});
view.dispatch({
changes,
selection: { anchor: changes.newLength },
changes: {
from: 0,
to: currentValue.length,
insert: value,
},
selection: {
anchor: value.length,
},
});
},
[],

View File

@@ -301,66 +301,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
dispatchSpy.mockRestore();
});
it('does not crash when the expression contains CRLF line breaks (issue #5869)', async () => {
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const initialExpression = "service.name = 'frontend'";
// Filtering on a multi-line log value (CRLF) used to throw
// "RangeError: Selection points outside of document".
const crlfExpression = "body CONTAINS 'line1\r\nline2\r\nline3'";
const baseQueryData = {
...initialQueriesMap.logs.builder.queryData[0],
filter: { expression: initialExpression },
};
const { rerender } = render(
<QuerySearch
onChange={onChange}
queryData={baseQueryData}
dataSource={DataSource.LOGS}
/>,
);
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent.textContent || '').toBe(initialExpression);
},
{ timeout: 3000 },
);
rerender(
<QuerySearch
onChange={onChange}
queryData={{ ...baseQueryData, filter: { expression: crlfExpression } }}
dataSource={DataSource.LOGS}
/>,
);
// The programmatic replace dispatched without throwing, and the selection anchor
// stayed within the CRLF-normalized document (the bug set it past the end).
await waitFor(() => {
const spec = dispatchSpy.mock.calls
.map(
(call) =>
call[0] as {
selection?: { anchor?: number };
changes?: { newLength?: number };
},
)
.find((s) => s?.selection?.anchor != null && s?.changes?.newLength != null);
expect(spec).toBeDefined();
expect(spec?.selection?.anchor).toBeLessThanOrEqual(
spec?.changes?.newLength as number,
);
});
dispatchSpy.mockRestore();
});
it('fetches key suggestions for metrics even without aggregateAttribute.key when showFilterSuggestionsWithoutMetric is true', async () => {
const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions

View File

@@ -130,28 +130,6 @@ describe('Footer utils', () => {
};
expect(validateCreateAlertState(currentArgs)).toBeNull();
});
it('when threshold channels are null', () => {
const currentArgs: BuildCreateAlertRulePayloadArgs = {
...args,
basicAlertState: {
...args.basicAlertState,
name: 'test name',
},
thresholdState: {
...args.thresholdState,
thresholds: [
{
...args.thresholdState.thresholds[0],
channels: null as unknown as string[],
},
],
},
};
expect(validateCreateAlertState(currentArgs)).toBe(
'Please select at least one channel for each threshold or enable routing policies',
);
});
});
describe('getNotificationSettingsProps', () => {

View File

@@ -44,8 +44,7 @@ export function validateCreateAlertState(
if (!threshold.label) {
return 'Please enter a label for each threshold';
}
// this runs during render, so a throw here takes down the whole page
if (!notificationSettings.routingPolicies && !threshold.channels?.length) {
if (!notificationSettings.routingPolicies && !threshold.channels.length) {
return 'Please select at least one channel for each threshold or enable routing policies';
}
}

View File

@@ -316,34 +316,6 @@ describe('CreateAlertV2 utils', () => {
});
});
describe('getThresholdStateFromAlertDef null channels', () => {
it('falls back to an empty array so downstream consumers never see null', () => {
const def: PostableAlertRuleV2 = {
...defaultPostableAlertRuleV2,
condition: {
...defaultPostableAlertRuleV2.condition,
thresholds: {
kind: 'basic',
spec: [
{
name: 'critical',
target: 1,
targetUnit: UniversalYAxisUnit.MINUTES,
channels: null as unknown as string[],
matchType: AlertThresholdMatchType.AT_LEAST_ONCE,
op: AlertThresholdOperator.IS_ABOVE,
},
],
},
},
};
expect(
getThresholdStateFromAlertDef(def).thresholds[0].channels,
).toStrictEqual([]);
});
});
describe('normalizeOperator', () => {
it.each([
['1', AlertThresholdOperator.IS_ABOVE],

View File

@@ -258,9 +258,7 @@ export function getThresholdStateFromAlertDef(
recoveryThresholdValue: null,
unit: threshold.targetUnit,
color: getColorForThreshold(threshold.name),
// rules created outside the UI can come back with a null channels
// field; drop the guard once the API enforces the schema
channels: threshold.channels ?? [],
channels: threshold.channels,
})) || [],
selectedQuery: alertDef.condition.selectedQueryName || '',
operator:

View File

@@ -22,7 +22,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
@@ -117,7 +116,6 @@ function EntityEventsContent({
: newUserExpression || '',
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -29,7 +29,6 @@ import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
@@ -133,7 +132,6 @@ function EntityLogsContent({
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.LOGS, newUserExpression);
querySearchOnRun(newUserExpression);
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -22,7 +22,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
@@ -99,7 +98,6 @@ function EntityTracesContent({
: newUserExpression || '',
);
if (validation.isValid) {
saveRecentQueryByExpression(DataSource.TRACES, newUserExpression);
querySearchOnRun(newUserExpression || '');
void logEvent(InfraMonitoringEvents.FilterApplied, {

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { DataSource } from 'types/common/queryBuilder';
import { MetricsSearchProps } from './types';
@@ -24,14 +23,12 @@ function MetricsSearch({
);
const handleStageAndRunQuery = useCallback(() => {
saveRecentQueryByExpression(DataSource.METRICS, currentQueryFilterExpression);
onChange(currentQueryFilterExpression);
onRunQuery?.();
}, [currentQueryFilterExpression, onChange, onRunQuery]);
const handleRunQuery = useCallback(
(expression: string): void => {
saveRecentQueryByExpression(DataSource.METRICS, expression);
setCurrentQueryFilterExpression(expression);
onChange(expression);
},

View File

@@ -33,7 +33,7 @@ jest.mock('hooks/useNotifications', () => ({
}),
}));
const RESET_PASSWORD_ENDPOINT = '*/api/v2/factor_password/reset';
const RESET_PASSWORD_ENDPOINT = '*/resetPassword';
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push

View File

@@ -1,12 +1,11 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Form, Input as AntdInput } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useResetPassword } from 'api/generated/services/users';
import resetPasswordApi from 'api/v1/factor_password/resetPassword';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import ROUTES from 'constants/routes';
@@ -15,6 +14,7 @@ import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { ArrowRight, CircleAlert, KeyRound } from '@signozhq/icons';
import { Label } from 'pages/SignUp/styles';
import APIError from 'types/api/error';
import { FormContainer } from './styles';
@@ -26,41 +26,40 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<APIError | null>();
const [isValidPassword, setIsValidPassword] = useState(false);
const [loading, setLoading] = useState(false);
const { t } = useTranslation(['common']);
const { search } = useLocation();
const params = new URLSearchParams(search);
const token = params.get('token');
const { notifications } = useNotifications();
const {
mutate: resetPassword,
isLoading,
error: mutationError,
} = useResetPassword();
const errorMessage = useMemo(
() => convertToApiError(mutationError),
[mutationError],
);
const [form] = Form.useForm<FormValues>();
const handleFormSubmit = (): void => {
const { password } = form.getFieldsValue();
const handleFormSubmit: () => Promise<void> = async () => {
try {
setLoading(true);
setErrorMessage(null);
const { password } = form.getFieldsValue();
resetPassword(
{ data: { password, token: token || '' } },
{
onSuccess: (): void => {
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
},
},
);
await resetPasswordApi({
password,
token: token || '',
});
notifications.success({
message: t('success', {
ns: 'common',
}),
});
history.push(ROUTES.LOGIN);
setLoading(false);
} catch (error) {
setLoading(false);
setErrorMessage(error as APIError);
}
};
const validatePassword = (): boolean => {
@@ -223,7 +222,7 @@ function ResetPassword({ version }: ResetPasswordProps): JSX.Element {
color="primary"
type="submit"
data-attr="reset-password"
disabled={!isValidPassword || isLoading}
disabled={!isValidPassword || loading}
className="reset-password-submit-button"
suffix={<ArrowRight size={16} />}
>

View File

@@ -1,22 +1,19 @@
import { useCallback, useMemo } from 'react';
import type {
AuthtypesGettableRoleDTO,
AuthtypesUserRoleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryClient } from 'react-query';
import type { AuthtypesGettableRoleDTO } from 'api/generated/services/sigNoz.schemas';
import {
useCreateUserRole,
useDeleteUserRole,
useGetUser,
getGetRolesByUserIDQueryKey,
useGetRolesByUserID,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
} from 'api/generated/services/users';
import { retryOn429 } from 'utils/errorUtils';
const enum PromiseStatus {
Fulfilled = 'fulfilled',
Rejected = 'rejected',
}
// Stable identity so the memos below do not recompute on every render.
const EMPTY_USER_ROLES: AuthtypesUserRoleDTO[] = [];
export interface MemberRoleUpdateFailure {
roleName: string;
error: unknown;
@@ -36,30 +33,30 @@ export function useMemberRoleManager(
userId: string,
enabled: boolean,
): UseMemberRoleManagerResult {
const { data, isLoading } = useGetUser(
const queryClient = useQueryClient();
const { data, isLoading } = useGetRolesByUserID(
{ id: userId },
{ query: { enabled: !!userId && enabled } },
);
const userRoles = data?.data?.userRoles ?? EMPTY_USER_ROLES;
const currentRoles = useMemo<AuthtypesGettableRoleDTO[]>(
() => userRoles.map((userRole) => userRole.role),
[userRoles],
() => data?.data ?? [],
[data?.data],
);
// DELETE /api/v2/user_roles/{id} is keyed by the user_role join row, not the role.
const assignmentIdByRoleId = useMemo(
() => new Map(userRoles.map((userRole) => [userRole.roleId, userRole.id])),
[userRoles],
);
const { mutateAsync: setRole } = useSetRoleByUserID({
mutation: { retry: retryOn429 },
});
const { mutateAsync: removeRole } = useRemoveUserRoleByUserIDAndRoleID({
mutation: { retry: retryOn429 },
});
const { mutateAsync: createUserRole } = useCreateUserRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteUserRole } = useDeleteUserRole({
mutation: { retry: retryOn429 },
});
const invalidateRoles = useCallback(
() =>
queryClient.invalidateQueries(getGetRolesByUserIDQueryKey({ id: userId })),
[userId, queryClient],
);
const applyDiff = useCallback(
async (
@@ -83,33 +80,30 @@ export function useMemberRoleManager(
const allOperations = [
...addedRoles.map((role) => ({
role,
run: (): ReturnType<typeof createUserRole> =>
createUserRole({ data: { userId, roleId: role.id ?? '' } }),
run: (): ReturnType<typeof setRole> =>
setRole({
pathParams: { id: userId },
data: { name: role.name ?? '' },
}),
})),
...removedRoles.map((role) => ({
role,
run: (): ReturnType<typeof removeRole> =>
removeRole({ pathParams: { id: userId, roleId: role.id ?? '' } }),
})),
...removedRoles
.map((role) => ({
role,
assignmentId: assignmentIdByRoleId.get(role.id ?? ''),
}))
.filter(
(
entry,
): entry is {
role: AuthtypesGettableRoleDTO;
assignmentId: string;
} => !!entry.assignmentId,
)
.map(({ role, assignmentId }) => ({
role,
run: (): ReturnType<typeof deleteUserRole> =>
deleteUserRole({ pathParams: { id: assignmentId } }),
})),
];
const results = await Promise.allSettled(
allOperations.map((op) => op.run()),
);
const successCount = results.filter(
(r) => r.status === PromiseStatus.Fulfilled,
).length;
if (successCount > 0) {
await invalidateRoles();
}
const failures: MemberRoleUpdateFailure[] = [];
results.forEach((result, index) => {
if (result.status === PromiseStatus.Rejected) {
@@ -119,6 +113,7 @@ export function useMemberRoleManager(
error: result.reason,
onRetry: async (): Promise<void> => {
await run();
await invalidateRoles();
},
});
}
@@ -126,7 +121,7 @@ export function useMemberRoleManager(
return failures;
},
[userId, currentRoles, assignmentIdByRoleId, createUserRole, deleteUserRole],
[userId, currentRoles, setRole, removeRole, invalidateRoles],
);
return { currentRoles, isLoading, applyDiff };

View File

@@ -19,30 +19,6 @@ type CompositeWithBuilder = {
builder?: { queryData?: IBuilderQuery[] };
};
export function saveRecentQueryByExpression(
dataSource: IBuilderQuery['dataSource'],
expression: string | null | undefined,
source = '',
): void {
const trimmed = expression?.trim();
if (!trimmed) {
return;
}
const validation = validateQuery(trimmed);
if (!validation.isValid) {
return;
}
const signal = toSignal(dataSource);
if (!signal) {
return;
}
store.save({
signal,
source,
filter: { expression: trimmed },
});
}
// Persists each builder query in the composite as a recent entry. Call this
// only from explicit user-driven Run triggers — reacting to stagedQuery or any
// other derived state pollutes recents with navigation/refresh/go-to traffic.
@@ -55,10 +31,22 @@ export function saveRecentQuery(
}
queryData.forEach((q) => {
saveRecentQueryByExpression(
q.dataSource,
q.filter?.expression,
q.source ?? '',
);
const expression = q.filter?.expression?.trim();
if (!expression) {
return;
}
const validation = validateQuery(expression);
if (!validation.isValid) {
return;
}
const signal = toSignal(q.dataSource);
if (!signal) {
return;
}
store.save({
signal,
source: q.source ?? '',
filter: q.filter ?? { expression: '' },
});
});
}

View File

@@ -182,56 +182,4 @@ describe('ValueSelector', () => {
});
});
});
describe('opening and closing without touching the list', () => {
function renderWith(
selection: VariableSelection,
options: string[],
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<ValueSelector
options={options}
variableType="dynamic"
multiSelect
showAllOption
selection={selection}
onChange={onChange}
emptyFallback={{ value: [], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
return onChange;
}
async function openThenClose(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
await user.keyboard('{Escape}');
}
it('does not promote a pick that covers every available option to ALL', async () => {
// A narrow time range can leave only the selected value in the list. That is
// still an explicit pick, not "everything, always".
const onChange = renderWith(
{ value: ['checkout-service-prod'], allSelected: false },
['checkout-service-prod'],
);
await openThenClose();
expect(onChange).not.toHaveBeenCalled();
});
it('does not rewrite a dynamic ALL into concrete values', async () => {
const onChange = renderWith({ value: null, allSelected: true }, OPTIONS);
await openThenClose();
expect(onChange).not.toHaveBeenCalled();
});
});
});

View File

@@ -145,133 +145,6 @@ describe('reconcileWithOptions', () => {
),
).toBeNull();
});
describe('preserveSelection (options moved on their own — time range, reload)', () => {
const multi = model({
type: 'DYNAMIC',
multiSelect: true,
showAllOption: true,
dynamicAttribute: 'service.name',
});
it('keeps a multi-select pick the new option list no longer offers', () => {
expect(
reconcileWithOptions(multi, { value: ['frontend'], allSelected: false }, [
'backend',
'cart',
]),
).toStrictEqual({ value: null, allSelected: true });
expect(
reconcileWithOptions(
multi,
{ value: ['frontend'], allSelected: false },
['backend', 'cart'],
{ preserveSelection: true },
),
).toBeNull();
});
it('still materializes ALL, which must track the option list', () => {
expect(
reconcileWithOptions(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
{ value: ['a'], allSelected: true },
['a', 'b'],
{ preserveSelection: true },
),
).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
it('still fills the default when nothing is selected yet', () => {
expect(
reconcileWithOptions(multi, { value: [], allSelected: false }, ['a', 'b'], {
preserveSelection: true,
}),
).toStrictEqual({ value: null, allSelected: true });
});
});
// A typed value is in no option list, so no refetch can invalidate it.
describe('customValues (typed in, never offered by the data)', () => {
const multi = model({
type: 'DYNAMIC',
multiSelect: true,
showAllOption: true,
dynamicAttribute: 'service.name',
});
it('keeps them through a re-scope that drops a fetched value', () => {
expect(
reconcileWithOptions(
multi,
{
value: ['frontend', 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
},
['backend', 'cart'],
),
).toStrictEqual({
value: ['typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
it('never re-defaults a selection made only of them', () => {
expect(
reconcileWithOptions(
multi,
{ value: ['typed-in'], allSelected: false, customValues: ['typed-in'] },
['backend', 'cart'],
),
).toBeNull();
});
// An inert marker is not worth a store write + dependent refetch to prune.
it('leaves a stale marker alone when it drops nothing', () => {
expect(
reconcileWithOptions(
multi,
{
value: ['frontend', 'typed-in'],
allSelected: false,
customValues: ['typed-in', 'removed-earlier'],
},
['frontend'],
),
).toBeNull();
});
it('prunes markers for values it does drop', () => {
expect(
reconcileWithOptions(
multi,
{
value: ['stale', 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
},
['frontend'],
),
).toStrictEqual({
value: ['typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
it('still drops an unmarked value the list no longer offers', () => {
expect(
reconcileWithOptions(
multi,
{ value: ['frontend', 'stale'], allSelected: false },
['frontend'],
),
).toStrictEqual({ value: ['frontend'], allSelected: false });
});
});
});
describe('configuredDefaultValue', () => {

View File

@@ -1,91 +0,0 @@
import type { VariableSelection } from '../selectionTypes';
import { selectionFromCommittedValues } from '../utils/selectionUtils';
const OPTIONS = ['checkout', 'payments', 'cart'];
const FALLBACK: VariableSelection = { value: null, allSelected: true };
function commit(
values: string[],
overrides: Partial<Parameters<typeof selectionFromCommittedValues>[0]> = {},
): VariableSelection {
return selectionFromCommittedValues({
values,
options: OPTIONS,
showAllOption: true,
emptyFallback: FALLBACK,
...overrides,
});
}
// What a multi-select commit resolves to. The option list is known only here, so this
// is the one place a typed value can be recognised.
describe('selectionFromCommittedValues', () => {
it('marks values the option list did not offer as typed in', () => {
expect(commit(['checkout', 'typed-in'])).toStrictEqual({
value: ['checkout', 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
it('marks a selection made only of typed-in values', () => {
expect(commit(['a', 'b'])).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
customValues: ['a', 'b'],
});
});
it('records no marker when every pick came from the list', () => {
expect(commit(['checkout', 'cart'])).toStrictEqual({
value: ['checkout', 'cart'],
allSelected: false,
});
});
it('reads a set covering every option as ALL', () => {
expect(commit(OPTIONS)).toStrictEqual({
value: OPTIONS,
allSelected: true,
});
});
// ALL re-materializes to the option set, so recording this as ALL would drop the
// typed value on the next refetch.
it('does not read every option PLUS a typed value as ALL', () => {
expect(commit([...OPTIONS, 'typed-in'])).toStrictEqual({
value: [...OPTIONS, 'typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
// Derived from the values + options at commit time, never from the old selection.
it('recomputes the marker: a typed value the data now offers is a normal pick', () => {
expect(
commit(['checkout', 'was-typed'], {
options: [...OPTIONS, 'was-typed'],
}),
).toStrictEqual({ value: ['checkout', 'was-typed'], allSelected: false });
});
it('does not read it as ALL when the variable offers no ALL', () => {
expect(commit(OPTIONS, { showAllOption: false })).toStrictEqual({
value: OPTIONS,
allSelected: false,
});
});
it('resolves an empty commit to the variable fallback', () => {
expect(commit([])).toBe(FALLBACK);
});
it('marks everything while the options have not arrived', () => {
// Nothing to judge against yet; erring this way keeps a value rather than dropping it.
expect(commit(['typed-in'], { options: [] })).toStrictEqual({
value: ['typed-in'],
allSelected: false,
customValues: ['typed-in'],
});
});
});

View File

@@ -4,8 +4,6 @@ import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { VariableCycleReason } from '../../store/slices/variableFetchSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import type { VariableSelection } from '../selectionTypes';
import { useAutoSelect } from '../hooks/useAutoSelect';
@@ -17,11 +15,7 @@ function run(
variable: VariableFormModel,
options: string[],
selection: VariableSelection,
cycleReason?: VariableCycleReason,
): VariableSelection | undefined {
useDashboardStore.setState({
variableCycleReasons: cycleReason ? { [variable.name]: cycleReason } : {},
});
const onAutoSelect = jest.fn();
renderHook(() => useAutoSelect(variable, options, selection, onAutoSelect));
return onAutoSelect.mock.calls[0]?.[0];
@@ -76,13 +70,11 @@ describe('useAutoSelect', () => {
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
// Re-scoped options only — a time-range refetch must NOT re-default; see below.
it('re-scoped: falls back to ALL, not the first option, when every selected value is gone', () => {
it('falls back to ALL, not the first option, when every selected value is gone', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
VariableCycleReason.ValueCascade,
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
@@ -110,23 +102,20 @@ describe('useAutoSelect', () => {
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('re-scoped: keeps the still-valid subset of a multi-select', () => {
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),
['a', 'b', 'd'],
{ value: ['a', 'b', 'c'], allSelected: false },
VariableCycleReason.ValueCascade,
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: false });
});
it('re-scoped: re-defaults a multi-select when none of the selected values remain', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),
['x', 'y'],
{ value: ['a', 'b'], allSelected: false },
VariableCycleReason.ValueCascade,
);
it('re-defaults a multi-select when none of the selected values remain', () => {
const next = run(model({ type: 'QUERY', multiSelect: true }), ['x', 'y'], {
value: ['a', 'b'],
allSelected: false,
});
expect(next).toStrictEqual({ value: ['x'], allSelected: false });
});
@@ -162,45 +151,4 @@ describe('useAutoSelect', () => {
});
expect(next).toBeUndefined();
});
describe('by cycle reason', () => {
const service = model({
name: 'service',
type: 'DYNAMIC',
multiSelect: true,
showAllOption: true,
dynamicAttribute: 'service.name',
});
const gone: VariableSelection = { value: ['frontend'], allSelected: false };
it('keeps the selection when a full cycle refetched the options', () => {
// The new window has no data for the selected service — no reason to widen to ALL.
const next = run(
service,
['backend', 'cart'],
gone,
VariableCycleReason.FullCycle,
);
expect(next).toBeUndefined();
});
it('re-scopes the selection when a value cascade refetched the options', () => {
const next = run(
service,
['backend', 'cart'],
gone,
VariableCycleReason.ValueCascade,
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('reconciles a variable with no cycle of its own (custom definition change)', () => {
const next = run(
model({ name: 'env', type: 'CUSTOM', multiSelect: true }),
['staging', 'prod'],
{ value: ['dev'], allSelected: false },
);
expect(next).toStrictEqual({ value: ['staging'], allSelected: false });
});
});
});

View File

@@ -13,11 +13,11 @@ jest.mock('nuqs', () => ({
useQueryState: (): unknown => [null, jest.fn()],
}));
const mockGlobalTime = { minTime: 1, maxTime: 2, selectedTime: '5m' };
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({ globalTime: mockGlobalTime }),
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
@@ -150,57 +150,3 @@ describe('useVariableSelection — setSelection', () => {
expect(svcCycleId()).toBe(before + 1);
});
});
describe('useVariableSelection — what a time-range change enqueues', () => {
// Longer than FETCH_CYCLE_DEBOUNCE_MS, which the hook keeps private.
const PAST_DEBOUNCE = 400;
function reasons(): Record<string, string> {
return useDashboardStore.getState().variableCycleReasons;
}
beforeEach(() => {
jest.useFakeTimers();
mockGlobalTime.selectedTime = '5m';
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
afterEach(() => {
jest.useRealTimers();
});
// The tag is what stops the reconcile re-defaulting a user's selection.
it('tags every variable as a full cycle, overriding an earlier cascade tag', () => {
const { result, rerender } = renderHook(() =>
useVariableSelection(dashboard),
);
act(() => {
jest.advanceTimersByTime(PAST_DEBOUNCE);
});
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
// A value change re-scopes the dependent's options: it may drop what no longer applies.
act(() => {
result.current.setSelection('env', { value: ['prod'], allSelected: false });
});
expect(reasons().svc).toBe('value-cascade');
mockGlobalTime.selectedTime = '30m';
rerender();
act(() => {
jest.advanceTimersByTime(PAST_DEBOUNCE);
});
expect(reasons()).toStrictEqual({ env: 'full-cycle', svc: 'full-cycle' });
});
});

View File

@@ -6,7 +6,6 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import type { VariableSelection } from '../../selectionTypes';
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
import OverflowValuesTooltip from './OverflowValuesTooltip';
import styles from '../../VariablesBar.module.scss';
@@ -76,23 +75,13 @@ function ValueSelector({
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// A close that left the list as it opened commits nothing — else a pick covering
// every option this window offers would be promoted to a standing ALL.
if (
areSelectionsEqual(
{ value: values, allSelected: false },
{ value: committedValues, allSelected: false },
)
) {
return;
}
const next = selectionFromCommittedValues({
values,
options,
showAllOption,
emptyFallback,
});
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
showAllOption &&
options.length > 0 &&
options.every((option) => values.includes(option));
const next: VariableSelection =
values.length === 0 ? emptyFallback : { value: values, allSelected: isAll };
// Closing without actually changing the selection must not re-fire onChange —
// that would needlessly re-cascade to dependent variables/panels.

View File

@@ -1,11 +1,6 @@
import { useEffect } from 'react';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import {
selectVariableCycleReason,
VariableCycleReason,
} from '../../store/slices/variableFetchSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { reconcileWithOptions } from '../utils/resolveVariableSelection';
import type { VariableSelection } from '../selectionTypes';
@@ -14,9 +9,6 @@ import type { VariableSelection } from '../selectionTypes';
* `onAutoSelect` only when the value must change. The reconcile rule lives in
* {@link reconcileWithOptions} (shared with seed + payload defaulting) so the bar
* and the panel query can never disagree about a variable's default.
*
* Only a value cascade may re-default the selection; a full cycle (time range,
* reload) leaves the user's pick alone. Types with no cycle of their own reconcile.
*/
export function useAutoSelect(
variable: VariableFormModel,
@@ -24,14 +16,8 @@ export function useAutoSelect(
selection: VariableSelection,
onAutoSelect: (selection: VariableSelection) => void,
): void {
const cycleReason = useDashboardStore(
selectVariableCycleReason(variable.name),
);
useEffect(() => {
const next = reconcileWithOptions(variable, selection, options, {
preserveSelection: cycleReason === VariableCycleReason.FullCycle,
});
const next = reconcileWithOptions(variable, selection, options);
if (next) {
onAutoSelect(next);
}

View File

@@ -10,11 +10,6 @@ export interface VariableSelection {
value: SelectedVariableValue;
/** True when every option is selected ("ALL"); for dynamic vars value may be null. */
allSelected: boolean;
/**
* Entries of `value` the user typed rather than picked. Never in any option list,
* so the reconcile keeps them instead of reading them as invalid.
*/
customValues?: string[];
}
/** Selected values for a dashboard's variables, keyed by variable name. */

View File

@@ -134,23 +134,12 @@ export function resolveDefaultSelection(
return { value: model.multiSelect ? [] : '', allSelected: false };
}
interface ReconcileOptions {
/**
* Set when no other variable caused this refetch (time-range change, reload): the
* selection then outranks the options and is kept as-is. Leave false for a
* dependency cascade, where a selection that no longer applies must give way.
*/
preserveSelection?: boolean;
}
/**
* Reconciles a variable's current selection against its freshly-fetched options.
* Returns the next selection, or null when nothing should change (a valid pick is
* left untouched — local-first). Behaviour, in order:
* - materialize ALL to the full option set (query/custom);
* - keep a multi-select selection outright when `preserveSelection` is set;
* - keep a still-valid multi-select subset, dropping only entries the list no longer
* offers and the user did not type in (`customValues`);
* - keep a still-valid multi-select subset, dropping only invalid entries;
* - otherwise auto-pick the default (or first option) so dependent variables and
* panels always resolve against a usable value.
*/
@@ -158,7 +147,6 @@ export function reconcileWithOptions(
model: VariableFormModel,
current: VariableSelection,
options: string[],
{ preserveSelection = false }: ReconcileOptions = {},
): VariableSelection | null {
if (options.length === 0) {
return null;
@@ -173,31 +161,13 @@ export function reconcileWithOptions(
Array.isArray(current.value) &&
current.value.length > 0
) {
// A pick this window has no data for is still the user's filter; re-defaulting it
// here is what widened a single pick to ALL on every time-range change.
if (preserveSelection) {
return null;
}
// A typed value is in no option list, so it is never "no longer offered".
const custom = new Set(current.customValues ?? []);
const valid = current.value
.map(String)
.filter((c) => options.includes(c) || custom.has(c));
const valid = current.value.map(String).filter((c) => options.includes(c));
if (valid.length === current.value.length) {
return null;
}
if (valid.length === 0) {
return fillDefault(model, options);
}
const customValues = valid.filter((v) => custom.has(v));
return {
value: valid,
allSelected: false,
...(customValues.length > 0 && { customValues }),
};
return valid.length > 0
? { value: valid, allSelected: false }
: fillDefault(model, options);
}
if (!model.multiSelect) {

View File

@@ -47,43 +47,6 @@ export function hasUsableValue(
return value !== '' && value !== null && value !== undefined;
}
interface CommittedValues {
values: string[];
options: string[];
showAllOption: boolean;
emptyFallback: VariableSelection;
}
/**
* The selection a multi-select commit resolves to. Options are known only here, so
* this is where a value the list never offered is recorded as typed in.
*/
export function selectionFromCommittedValues({
values,
options,
showAllOption,
emptyFallback,
}: CommittedValues): VariableSelection {
if (values.length === 0) {
return emptyFallback;
}
const customValues = values.filter((value) => !options.includes(value));
// ALL re-materializes to the option set, so a set carrying a typed value is not ALL
// — the next refetch would expand it back and drop what the user typed.
const allSelected =
showAllOption &&
options.length > 0 &&
customValues.length === 0 &&
options.every((option) => values.includes(option));
return {
value: values,
allSelected,
...(customValues.length > 0 && { customValues }),
};
}
/** Flatten the selection map into the `{ name: value }` payload a query expects. */
export function selectionToPayload(
selection: VariableSelectionMap,

View File

@@ -34,7 +34,6 @@ function reset(names: string[], context: VariableFetchContext): void {
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableFetchContext: null,
});
store().initVariableFetch(names, context);
@@ -134,33 +133,6 @@ describe('variableFetchSlice', () => {
expect(states().q1).toBe('error');
expect(states().q2).toBe('idle');
});
// The reason is what tells the post-fetch reconcile whether it may re-default a
// selection: a full cycle must not, a value cascade must.
it('tags a full cycle, then re-tags only the cascaded variables', () => {
store().enqueueFetchAll();
expect(store().variableCycleReasons).toStrictEqual({
q1: 'full-cycle',
q2: 'full-cycle',
d1: 'full-cycle',
d2: 'full-cycle',
});
resolve('q1');
store().enqueueDescendants('q1');
expect(store().variableCycleReasons).toStrictEqual({
q1: 'full-cycle',
q2: 'value-cascade',
d1: 'full-cycle',
d2: 'full-cycle',
});
});
it('drops the reason for a variable that no longer exists', () => {
store().enqueueFetchAll();
store().initVariableFetch(['q1'], context);
expect(store().variableCycleReasons).toStrictEqual({ q1: 'full-cycle' });
});
});
describe('variableFetchSlice — query depends on a dynamic', () => {

View File

@@ -9,7 +9,6 @@ import {
type FetchMaps,
isVariableInActiveFetchState,
resolveFetchState,
VariableCycleReason,
VariableFetchState,
} from './variableFetchSlice.utils';
@@ -31,10 +30,7 @@ function queryParentsHaveValues(
);
}
export {
VariableCycleReason,
VariableFetchState,
} from './variableFetchSlice.utils';
export { VariableFetchState } from './variableFetchSlice.utils';
/**
* Runtime fetch orchestration for dashboard variables — native port of V1's
@@ -49,8 +45,6 @@ export interface VariableFetchSlice {
variableFetchStates: Record<string, VariableFetchState>;
variableLastUpdated: Record<string, number>;
variableCycleIds: Record<string, number>;
/** Why each variable's current cycle was enqueued, read by the post-fetch reconcile. */
variableCycleReasons: Record<string, VariableCycleReason>;
/**
* Whether a QUERY/DYNAMIC variable settled its fetch with zero options (so it
* will never get a value). Lets a dependent panel fall through to "no data"
@@ -112,7 +106,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
@@ -122,7 +115,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableCycleReasons: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
@@ -140,7 +132,6 @@ export const createVariableFetchSlice: StateCreator<
initVariableFetch: (names, context): void => {
const maps = cloneMaps(get());
const resolvedEmpty = { ...get().variableResolvedEmpty };
const reasons = { ...get().variableCycleReasons };
names.forEach((name) => {
if (!maps.states[name]) {
maps.states[name] = VariableFetchState.Idle;
@@ -153,14 +144,12 @@ export const createVariableFetchSlice: StateCreator<
delete maps.lastUpdated[name];
delete maps.cycleIds[name];
delete resolvedEmpty[name];
delete reasons[name];
}
});
set({
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableCycleReasons: reasons,
variableResolvedEmpty: resolvedEmpty,
variableFetchContext: context,
});
@@ -182,11 +171,6 @@ export const createVariableFetchSlice: StateCreator<
dynamicVariableOrder,
} = variableFetchContext;
const maps = cloneMaps(get());
const reasons = { ...get().variableCycleReasons };
const bump = (name: string): void => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
reasons[name] = VariableCycleReason.FullCycle;
};
// Query variables wait only for their QUERY parents. A DYNAMIC parent does not
// gate: its option fetch feeds only its own dropdown, while its selected value
@@ -194,7 +178,7 @@ export const createVariableFetchSlice: StateCreator<
// dependent query substitutes it immediately and refetches via the cascade if
// it later changes. Text/custom parents resolve synchronously, so nothing waits.
queryVariableOrder.forEach((name) => {
bump(name);
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
const parents = dependencyData.parentGraph[name] || [];
const hasQueryParents = parents.some((p) => variableTypes[p] === 'QUERY');
maps.states[name] = hasQueryParents
@@ -208,7 +192,7 @@ export const createVariableFetchSlice: StateCreator<
const orderedQuery = new Set(queryVariableOrder);
Object.keys(variableTypes).forEach((name) => {
if (variableTypes[name] === 'QUERY' && !orderedQuery.has(name)) {
bump(name);
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = resolveFetchState(maps, name);
}
});
@@ -219,7 +203,7 @@ export const createVariableFetchSlice: StateCreator<
// populate fast even when query variables are slow; a sibling selection change
// later refetches them via `enqueueDescendantsBatch`.
dynamicVariableOrder.forEach((name) => {
bump(name);
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
maps.states[name] = resolveFetchState(maps, name);
});
@@ -227,7 +211,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableCycleReasons: reasons,
lastFetchAllKey: key ?? get().lastFetchAllKey,
});
},
@@ -307,11 +290,6 @@ export const createVariableFetchSlice: StateCreator<
const { dependencyData, variableTypes, dynamicVariableOrder } =
variableFetchContext;
const maps = cloneMaps(get());
const reasons = { ...get().variableCycleReasons };
const bump = (name: string): void => {
maps.cycleIds[name] = (maps.cycleIds[name] || 0) + 1;
reasons[name] = VariableCycleReason.ValueCascade;
};
const changed = new Set(names);
// Callers commit values before this runs, so the gate sees the new parent values.
const selection = selectVariableValues(get().dashboardId)(get());
@@ -327,7 +305,7 @@ export const createVariableFetchSlice: StateCreator<
});
});
queryDescendants.forEach((desc) => {
bump(desc);
maps.cycleIds[desc] = (maps.cycleIds[desc] || 0) + 1;
maps.states[desc] = queryParentsHaveValues(
desc,
variableFetchContext,
@@ -344,7 +322,7 @@ export const createVariableFetchSlice: StateCreator<
dynamicVariableOrder
.filter((dynName) => !changed.has(dynName))
.forEach((dynName) => {
bump(dynName);
maps.cycleIds[dynName] = (maps.cycleIds[dynName] || 0) + 1;
maps.states[dynName] = resolveFetchState(maps, dynName);
});
}
@@ -353,7 +331,6 @@ export const createVariableFetchSlice: StateCreator<
variableFetchStates: maps.states,
variableLastUpdated: maps.lastUpdated,
variableCycleIds: maps.cycleIds,
variableCycleReasons: reasons,
});
},
});
@@ -370,12 +347,6 @@ export const selectVariableCycleId =
(state: DashboardStore): number =>
state.variableCycleIds[name] ?? 0;
/** Selector: why a variable's cycle was enqueued. Undefined for types that never fetch. */
export const selectVariableCycleReason =
(name: string) =>
(state: DashboardStore): VariableCycleReason | undefined =>
state.variableCycleReasons[name];
/** Selector: whether a variable has completed at least one fetch. */
export const selectVariableFetchedOnce =
(name: string) =>

View File

@@ -7,14 +7,6 @@ export enum VariableFetchState {
Error = 'error',
}
/** Why a cycle was started — only a cascade may re-default a user's selection. */
export enum VariableCycleReason {
/** `enqueueFetchAll`: load, time-range or variable-order change. */
FullCycle = 'full-cycle',
/** `enqueueDescendantsBatch`: a parent or sibling variable's value changed. */
ValueCascade = 'value-cascade',
}
/** Mutable clones a fetch action works over before committing back in one `set`. */
export interface FetchMaps {
states: Record<string, VariableFetchState>;

View File

@@ -0,0 +1,9 @@
export interface Props {
token: string;
password: string;
}
export interface PayloadProps {
data: string;
status: string;
}

View File

@@ -51,7 +51,7 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSavedView.Scope(coretypes.VerbCreate)}),
},

View File

@@ -245,13 +245,11 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
// Read the storable, not the decoded v2 dashboard: deleting must work even
// when the stored data is corrupt or never migrated off the v1 schema.
storable, err := module.store.Get(ctx, orgID, id)
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := storable.ErrIfNotDeletable(); err != nil {
if err := existing.ErrIfNotDeletable(); err != nil {
return err
}

View File

@@ -39,28 +39,26 @@ type legacyExtraData struct {
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
var legacy legacyExtraData
if v.ExtraData != "" {
// Best-effort: malformed/older extraData shapes never fail the request.
// Best-effort: malformed/older extraData shapes never fail the request
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
}
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
return savedviewtypes.PostableSavedView{
GenerateName: true,
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: panelType,
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
GenerateName: true,
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
},
},
}
@@ -70,27 +68,25 @@ func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Postable
func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.UpdatableSavedView {
var legacy legacyExtraData
if v.ExtraData != "" {
// Best-effort: malformed/older extraData shapes never fail the request.
// Best-effort: malformed/older extraData shapes never fail the request
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
}
panelType := savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))}
return savedviewtypes.UpdatableSavedView{
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: panelType,
// v1 has no requestType concept of its own -- fall back to the panelType-derived guess.
RequestType: savedviewtypes.LegacyRequestTypeForPanelType(panelType),
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: v.Name,
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
Queries: v.CompositeQuery.Queries,
SelectedFields: legacy.SelectColumns,
Display: savedviewtypes.Display{
MaxLines: legacy.MaxLines,
FontSize: legacy.FontSize,
Format: legacy.Format,
Color: legacy.Color,
},
},
},
}
@@ -99,11 +95,11 @@ func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Updatab
// newLegacyViewFromSavedView renders a v2 SavedView back into the v1 shape.
func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, error) {
extraData, err := json.Marshal(legacyExtraData{
Color: v.Spec.Display.Color,
SelectColumns: v.Spec.SelectedFields,
Format: v.Spec.Display.Format,
MaxLines: v.Spec.Display.MaxLines,
FontSize: v.Spec.Display.FontSize,
Color: v.Data.Spec.Display.Color,
SelectColumns: v.Data.Spec.SelectedFields,
Format: v.Data.Spec.Display.Format,
MaxLines: v.Data.Spec.Display.MaxLines,
FontSize: v.Data.Spec.Display.FontSize,
})
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
@@ -111,17 +107,17 @@ func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, err
return &v3.SavedView{
ID: v.ID,
Name: v.Spec.DisplayName,
Name: v.Data.Spec.DisplayName,
CreatedAt: v.CreatedAt,
CreatedBy: v.CreatedBy,
UpdatedAt: v.UpdatedAt,
UpdatedBy: v.UpdatedBy,
SourcePage: v.Source.StringValue(),
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
PanelType: v3.PanelType(v.Data.Spec.PanelType.StringValue()),
// Saved views are only ever created from the explorer's builder mode.
QueryType: v3.QueryTypeBuilder,
Queries: v.Spec.Queries,
Queries: v.Data.Spec.Queries,
},
ExtraData: string(extraData),
}, nil
@@ -160,14 +156,7 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
return
}
postable := newPostableSavedViewFromLegacyView(&view)
if err := postable.Validate(); err != nil {
render.Error(w, err)
return
}
uuid, err := handler.module.CreateView(ctx, claims.OrgID, postable)
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
if err != nil {
render.Error(w, err)
return
@@ -235,14 +224,8 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
return
}
updatable := newUpdatableSavedViewFromLegacyView(&view)
if err := updatable.Validate(); err != nil {
render.Error(w, err)
return
}
if err := handler.module.UpdateView(ctx, claims.OrgID, viewUUID, updatable); err != nil {
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newUpdatableSavedViewFromLegacyView(&view))
if err != nil {
render.Error(w, err)
return
}

View File

@@ -42,14 +42,13 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
assert.Empty(t, postable.Name, "v1 has no slug concept -- name must always be generated")
assert.True(t, postable.GenerateName, "v1 has no slug concept -- name must always be generated")
assert.Equal(t, "my view", postable.Spec.DisplayName)
assert.Equal(t, "my view", postable.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceLogs, postable.Source)
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.SchemaVersion)
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Spec.PanelType)
assert.Equal(t, qbtypes.RequestTypeTimeSeries, postable.Spec.RequestType, "graph panel type must map to the time_series request type")
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Spec.Queries)
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Spec.Display)
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.Data.SchemaVersion)
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Data.Spec.PanelType)
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Data.Spec.Queries)
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Data.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Data.Spec.Display)
})
t.Run("empty extra data leaves display and selected fields zero-valued", func(t *testing.T) {
@@ -65,9 +64,8 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, savedviewtypes.PanelTypeTable, postable.Spec.PanelType)
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
assert.Nil(t, postable.Spec.SelectedFields)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
assert.Nil(t, postable.Data.Spec.SelectedFields)
})
t.Run("malformed extra data is ignored, not an error", func(t *testing.T) {
@@ -83,48 +81,8 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, "malformed extra data", postable.Spec.DisplayName)
assert.Equal(t, savedviewtypes.PanelTypeList, postable.Spec.PanelType)
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
})
t.Run("legacy validation gap: empty builderQueries map with no queries", func(t *testing.T) {
legacy := &v3.SavedView{
Name: "no real queries",
SourcePage: "logs",
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelTypeGraph,
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{},
},
}
require.NoError(t, legacy.Validate(), "the legacy CompositeQuery check is expected to miss this")
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Error(t, postable.Validate(), "the converted postable must catch what the legacy check missed")
})
t.Run("list panel query with no aggregation is valid", func(t *testing.T) {
legacy := &v3.SavedView{
Name: "raw list view",
SourcePage: "traces",
CompositeQuery: &v3.CompositeQuery{
PanelType: v3.PanelTypeList,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "db_name = 'two'"},
},
}},
},
}
postable := newPostableSavedViewFromLegacyView(legacy)
assert.Equal(t, qbtypes.RequestTypeRaw, postable.Spec.RequestType, "list panel type must map to the raw request type")
assert.NoError(t, postable.Validate(), "a raw list query must not be required to carry an aggregation")
assert.Equal(t, "malformed extra data", postable.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
})
}
@@ -141,23 +99,24 @@ func TestNewUpdatableSavedViewFromLegacyView(t *testing.T) {
updatable := newUpdatableSavedViewFromLegacyView(legacy)
assert.Equal(t, "renamed view", updatable.Spec.DisplayName)
assert.Equal(t, "renamed view", updatable.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceTraces, updatable.Source)
assert.Equal(t, qbtypes.RequestTypeScalar, updatable.Spec.RequestType, "table panel type must map to the scalar request type")
}
func TestNewLegacyViewFromSavedView(t *testing.T) {
now := time.Now()
savedView := &savedviewtypes.SavedView{
Name: "my-view-abc123ef",
Source: savedviewtypes.SourceLogs,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "my view",
PanelType: savedviewtypes.PanelTypeGraph,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
Name: "my-view-abc123ef",
Source: savedviewtypes.SourceLogs,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "my view",
PanelType: savedviewtypes.PanelTypeGraph,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
},
},
}
savedView.ID = valuer.GenerateUUID()
@@ -170,7 +129,7 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, savedView.ID, legacy.ID)
assert.Equal(t, savedView.Spec.DisplayName, legacy.Name)
assert.Equal(t, savedView.Data.Spec.DisplayName, legacy.Name)
assert.Equal(t, savedView.CreatedAt, legacy.CreatedAt)
assert.Equal(t, savedView.CreatedBy, legacy.CreatedBy)
assert.Equal(t, savedView.UpdatedAt, legacy.UpdatedAt)
@@ -178,20 +137,20 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
assert.Equal(t, "logs", legacy.SourcePage)
assert.Equal(t, v3.PanelTypeGraph, legacy.CompositeQuery.PanelType)
assert.Equal(t, v3.QueryTypeBuilder, legacy.CompositeQuery.QueryType)
assert.Equal(t, savedView.Spec.Queries, legacy.CompositeQuery.Queries)
assert.Equal(t, savedView.Data.Spec.Queries, legacy.CompositeQuery.Queries)
var extra legacyExtraData
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
assert.Equal(t, "blue", extra.Color)
assert.Equal(t, savedView.Spec.SelectedFields, extra.SelectColumns)
assert.Equal(t, savedView.Data.Spec.SelectedFields, extra.SelectColumns)
assert.Equal(t, "table", extra.Format)
assert.Equal(t, 10, extra.MaxLines)
assert.Equal(t, "large", extra.FontSize)
}
func TestNewLegacyViewsFromSavedViews(t *testing.T) {
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}}
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}}
legacyViews, err := newLegacyViewsFromSavedViews([]*savedviewtypes.SavedView{a, b})
require.NoError(t, err)
@@ -208,15 +167,17 @@ func TestNewLegacyViewsFromSavedViews(t *testing.T) {
// slug (Name) is deliberately NOT part of this contract -- v1 never sees it.
func TestLegacyViewRoundTrip(t *testing.T) {
original := &savedviewtypes.SavedView{
Name: "round-trip-abc123ef",
Source: savedviewtypes.SourceMetrics,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
Name: "round-trip-abc123ef",
Source: savedviewtypes.SourceMetrics,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
},
},
}
@@ -227,37 +188,10 @@ func TestLegacyViewRoundTrip(t *testing.T) {
assert.Empty(t, roundTripped.Name)
assert.True(t, roundTripped.GenerateName)
assert.Equal(t, original.Spec.DisplayName, roundTripped.Spec.DisplayName)
assert.Equal(t, original.Data.Spec.DisplayName, roundTripped.Data.Spec.DisplayName)
assert.Equal(t, original.Source, roundTripped.Source)
assert.Equal(t, original.Spec.Queries, roundTripped.Spec.Queries)
assert.Equal(t, original.Spec.SelectedFields, roundTripped.Spec.SelectedFields)
assert.Equal(t, original.Spec.PanelType, roundTripped.Spec.PanelType)
assert.Equal(t, original.Spec.Display, roundTripped.Spec.Display)
}
func TestLegacyViewRoundTrip_EmptySelectedFieldsAndDisplay(t *testing.T) {
original := &savedviewtypes.SavedView{
Name: "round-trip-empty-abc123ef",
Source: savedviewtypes.SourceMetrics,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: "round trip empty",
PanelType: savedviewtypes.PanelTypeTable,
Queries: testQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
}
legacy, err := newLegacyViewFromSavedView(original)
require.NoError(t, err)
var extra legacyExtraData
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
assert.Nil(t, extra.SelectColumns, "omitempty drops an empty selectColumns from extraData entirely")
roundTripped := newPostableSavedViewFromLegacyView(legacy)
assert.Empty(t, roundTripped.Spec.SelectedFields, "empty, not necessarily non-nil, on this leg of the round trip")
assert.Equal(t, savedviewtypes.PanelTypeTable, roundTripped.Spec.PanelType)
assert.Equal(t, savedviewtypes.Display{}, roundTripped.Spec.Display)
assert.Equal(t, original.Data.Spec.PanelType, roundTripped.Data.Spec.PanelType)
assert.Equal(t, original.Data.Spec.Queries, roundTripped.Data.Spec.Queries)
assert.Equal(t, original.Data.Spec.SelectedFields, roundTripped.Data.Spec.SelectedFields)
assert.Equal(t, original.Data.Spec.Display, roundTripped.Data.Spec.Display)
}

View File

@@ -19,11 +19,7 @@ func NewModule(store savedviewtypes.Store) savedview.Module {
}
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
storables, err := module.store.List(ctx, orgID, source, name)
if err != nil {
return nil, err
}
return savedviewtypes.NewSavedViewsFromStorableSavedViews(storables), nil
return module.store.List(ctx, orgID, source, name)
}
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
@@ -34,19 +30,14 @@ func (module *module) CreateView(ctx context.Context, orgID string, view savedvi
dbView := view.ToSavedView(orgID, claims.Email)
if err := module.store.Create(ctx, savedviewtypes.NewStorableSavedView(dbView)); err != nil {
if err := module.store.Create(ctx, dbView); err != nil {
return valuer.UUID{}, err
}
return dbView.ID, nil
}
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.SavedView, error) {
storable, err := module.store.Get(ctx, orgID, uuid)
if err != nil {
return nil, err
}
return storable.ToSavedView(), nil
return module.store.Get(ctx, orgID, uuid)
}
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
@@ -55,8 +46,7 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
}
dbView := view.ToSavedView(uuid, orgID, claims.Email)
return module.store.Update(ctx, savedviewtypes.NewStorableSavedView(dbView))
return module.store.Update(ctx, view.ToSavedView(uuid, orgID, claims.Email))
}
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
@@ -64,10 +54,10 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
storables, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
savedViews, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
if err != nil {
return nil, err
}
return savedviewtypes.NewStatsFromStorableSavedViews(storables), nil
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
}

View File

@@ -28,23 +28,24 @@ func newTestStore() (savedview.Module, *savedviewtypestest.StoreTest) {
func testPostableSavedView(name string, source savedviewtypes.Source) savedviewtypes.PostableSavedView {
return savedviewtypes.PostableSavedView{
Name: name,
Source: source,
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: name,
PanelType: savedviewtypes.PanelTypeGraph,
RequestType: qbtypes.RequestTypeTimeSeries,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
Name: name,
Source: source,
Data: savedviewtypes.SavedViewData{
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
Spec: savedviewtypes.SavedViewSpec{
DisplayName: name,
PanelType: savedviewtypes.PanelTypeGraph,
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
},
},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
}
}
@@ -52,9 +53,8 @@ func testPostableSavedView(name string, source savedviewtypes.Source) savedviewt
func testUpdatableSavedView(displayName string, source savedviewtypes.Source) savedviewtypes.UpdatableSavedView {
postable := testPostableSavedView(displayName, source)
return savedviewtypes.UpdatableSavedView{
Source: postable.Source,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
Source: postable.Source,
Data: postable.Data,
}
}
@@ -93,22 +93,7 @@ func TestModule_CreateAndGetView(t *testing.T) {
assert.Equal(t, savedviewtypes.SourceLogs, got.Source)
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
require.NoError(t, st.AssertExpectations())
}
// A duplicate-name insert failure must surface as errors.TypeAlreadyExists, not a generic internal error.
func TestModule_CreateView_DuplicateNameIsConflict(t *testing.T) {
m, st := newTestStore()
orgID := valuer.GenerateUUID().StringValue()
ctx := contextWithClaims(orgID, "creator@signoz.io")
st.ExpectCreateError(errors.Newf(errors.TypeInternal, errors.CodeInternal, "UNIQUE constraint failed: saved_view.org_id, saved_view.name"))
_, err := m.CreateView(ctx, orgID, testPostableSavedView("same-name", savedviewtypes.SourceLogs))
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeAlreadyExists), "expected an already-exists error, got %v", err)
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Data.Spec.PanelType)
require.NoError(t, st.AssertExpectations())
}
@@ -153,21 +138,21 @@ func TestModule_UpdateView(t *testing.T) {
existingName := existing.Name
updated := testUpdatableSavedView("renamed", savedviewtypes.SourceTraces)
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
updated.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
st.ExpectUpdate(orgID, id, 1)
require.NoError(t, m.UpdateView(contextWithClaims(orgID, "updater@signoz.io"), orgID, id, updated))
stored := testSavedView(orgID, id, "updater@signoz.io", testPostableSavedView("renamed", savedviewtypes.SourceTraces))
stored.Name = existingName
stored.Spec.PanelType = savedviewtypes.PanelTypeTable
stored.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
st.ExpectGet(orgID, id, stored)
got, err := m.GetView(contextWithClaims(orgID, "creator@signoz.io"), orgID, id)
require.NoError(t, err)
assert.Equal(t, existingName, got.Name, "name must not change on update")
assert.Equal(t, "renamed", got.Spec.DisplayName)
assert.Equal(t, "renamed", got.Data.Spec.DisplayName)
assert.Equal(t, savedviewtypes.SourceTraces, got.Source)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Data.Spec.PanelType)
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
require.NoError(t, st.AssertExpectations())

View File

@@ -6,6 +6,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -17,31 +18,32 @@ func NewStore(sqlstore sqlstore.SQLStore) savedviewtypes.Store {
return &store{sqlstore: sqlstore}
}
func (store *store) Create(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(storable).Exec(ctx)
func (store *store) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
_, err := store.sqlstore.BunDB().NewInsert().Model(view).Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", storable.Name)
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", view.Name)
}
return nil
}
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.StorableSavedView, error) {
var storable savedviewtypes.StorableSavedView
err := store.sqlstore.BunDB().NewSelect().Model(&storable).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
var view savedviewtypes.SavedView
err := store.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
}
return &storable, nil
normalizeSelectedFields(&view)
return &view, nil
}
func (store *store) Update(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
res, err := store.sqlstore.BunDB().NewUpdate().
Model((*savedviewtypes.StorableSavedView)(nil)).
Model(&savedviewtypes.SavedView{}).
Set("updated_at = ?, updated_by = ?, source = ?, data = ?",
storable.UpdatedAt, storable.UpdatedBy, storable.Source, storable.Data).
Where("id = ?", storable.ID.StringValue()).
Where("org_id = ?", storable.OrgID).
view.UpdatedAt, view.UpdatedBy, view.Source, view.Data).
Where("id = ?", view.ID.StringValue()).
Where("org_id = ?", view.OrgID).
Exec(ctx)
if err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
@@ -52,7 +54,7 @@ func (store *store) Update(ctx context.Context, storable *savedviewtypes.Storabl
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
}
if rowsAffected == 0 {
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", storable.ID.StringValue())
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", view.ID.StringValue())
}
return nil
@@ -60,7 +62,7 @@ func (store *store) Update(ctx context.Context, storable *savedviewtypes.Storabl
func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
res, err := store.sqlstore.BunDB().NewDelete().
Model((*savedviewtypes.StorableSavedView)(nil)).
Model(&savedviewtypes.SavedView{}).
Where("id = ?", id.StringValue()).
Where("org_id = ?", orgID).
Exec(ctx)
@@ -79,9 +81,9 @@ func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) er
return nil
}
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.StorableSavedView, error) {
var storables []*savedviewtypes.StorableSavedView
q := store.sqlstore.BunDB().NewSelect().Model(&storables).
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
var views []*savedviewtypes.SavedView
q := store.sqlstore.BunDB().NewSelect().Model(&views).
Where("org_id = ?", orgID).
Where("name LIKE ?", "%"+name+"%")
if !source.IsZero() {
@@ -92,5 +94,16 @@ func (store *store) List(ctx context.Context, orgID string, source savedviewtype
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
}
return storables, nil
for _, view := range views {
normalizeSelectedFields(view)
}
return views, nil
}
// normalizeSelectedFields fixes up a scanned row's nil SelectedFields.
func normalizeSelectedFields(view *savedviewtypes.SavedView) {
if view.Data.Spec.SelectedFields == nil {
view.Data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
}
}

View File

@@ -237,8 +237,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore, sqlschema),
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
)
}

View File

@@ -1,220 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
// knownQueryTypes mirrors the discriminator values qbtypes.QueryType currently defines.
var knownQueryTypes = map[string]bool{
"builder_query": true,
"builder_ai_query": true,
"builder_formula": true,
"builder_sub_query": true,
"builder_join": true,
"builder_trace_operator": true,
"clickhouse_sql": true,
"promql": true,
}
// specFieldZeroValueJSON is the JSON to substitute for a spec key that fails to unmarshal.
var specFieldZeroValueJSON = map[string]string{
"displayName": `""`,
"panelType": `""`,
"queries": `[]`,
"selectedFields": `[]`,
"display": `{}`,
}
// storableSavedViewData is the shape of the `saved_view` table this migration repairs.
type storableSavedViewData struct {
bun.BaseModel `bun:"table:saved_view"`
ID string `bun:"id,pk,type:text"`
Data string `bun:"data,type:text"`
}
// queryEnvelope mirrors minimal required qbtypes.QueryEnvelope.
type queryEnvelope struct {
Type string `json:"type"`
Spec json.RawMessage `json:"spec"`
}
// telemetryFieldKey mirrors telemetrytypes.TelemetryFieldKey's JSON-visible fields.
// Signal/FieldContext/FieldDataType are plain strings to test UnmarshalJSON.
type telemetryFieldKey struct {
Name string `json:"name"`
Description string `json:"description"`
Unit string `json:"unit"`
Signal string `json:"signal"`
FieldContext string `json:"fieldContext"`
FieldDataType string `json:"fieldDataType"`
}
// fixDisplay mirrors savedviewtypes.Display.
type fixDisplay struct {
MaxLines int `json:"maxLines"`
FontSize string `json:"fontSize"`
Format string `json:"format"`
Color string `json:"color"`
}
// fixSpec mirrors savedviewtypes.SavedViewSpec.
type fixSpec struct {
DisplayName string `json:"displayName"`
PanelType string `json:"panelType"`
Queries []queryEnvelope `json:"queries"`
SelectedFields []telemetryFieldKey `json:"selectedFields"`
Display fixDisplay `json:"display"`
}
// fixData mirrors savedviewtypes.SavedViewData.
type fixData struct {
SchemaVersion string `json:"schemaVersion"`
Spec fixSpec `json:"spec"`
}
type fixSavedViewSelectedFields struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewFixSavedViewSelectedFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("fix_saved_view_selected_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fixSavedViewSelectedFields{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *fixSavedViewSelectedFields) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *fixSavedViewSelectedFields) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableSavedViewData
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var repaired, deleted int
for _, row := range rows {
fixedData, blanked, ok := repairSavedViewData(row.Data)
if ok && len(blanked) == 0 {
// already scans cleanly field-by-field -- nothing to repair.
continue
}
if !ok {
migration.settings.Logger.WarnContext(ctx, "saved view data could not be repaired field-by-field, deleting the row", slog.String("saved_view_id", row.ID), slog.String("raw_data", row.Data))
if _, err := tx.NewDelete().Model((*storableSavedViewData)(nil)).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
deleted++
continue
}
repaired++
migration.settings.Logger.WarnContext(ctx, "repaired saved view data by blanking fields that failed to unmarshal", slog.String("saved_view_id", row.ID), slog.Any("fields_blanked", blanked))
if _, err := tx.NewUpdate().Model((*storableSavedViewData)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "checked saved views for unreadable data", slog.Int("total", len(rows)), slog.Int("repaired", repaired), slog.Int("deleted", deleted))
return tx.Commit()
}
func (migration *fixSavedViewSelectedFields) Down(context.Context, *bun.DB) error {
return nil
}
// specFieldUnmarshalsCleanly reports whether value can be unmarshalled into
// the expected shape of the given savedviewtypes.SavedViewSpec JSON key.
func specFieldUnmarshalsCleanly(key string, value json.RawMessage) bool {
switch key {
case "displayName", "panelType":
var s string
return json.Unmarshal(value, &s) == nil
case "queries":
var q []queryEnvelope
if err := json.Unmarshal(value, &q); err != nil {
return false
}
if q == nil {
// a JSON null unmarshals into a nil slice with no error; treat it as unclean so it
// gets blanked to [] rather than shipping "queries": null against a nullable:false schema.
return false
}
for _, e := range q {
if !knownQueryTypes[e.Type] || len(e.Spec) == 0 {
return false
}
}
return true
case "selectedFields":
var f []telemetryFieldKey
if err := json.Unmarshal(value, &f); err != nil {
return false
}
// same null-vs-[] gap as "queries" above: blank a JSON null to [] instead of leaving it.
return f != nil
case "display":
var d fixDisplay
return json.Unmarshal(value, &d) == nil
default:
return true
}
}
// repairSavedViewData tries to make data unmarshal cleanly by blanking, one key at a time,
// whichever top-level spec fields fail to unmarshal into their expected shape.
func repairSavedViewData(data string) (fixed string, blanked []string, ok bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "", nil, false
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return "", nil, false
}
for key, value := range spec {
if specFieldUnmarshalsCleanly(key, value) {
continue
}
spec[key] = json.RawMessage(specFieldZeroValueJSON[key])
blanked = append(blanked, key)
}
fixedSpec, err := json.Marshal(spec)
if err != nil {
return "", nil, false
}
raw["spec"] = fixedSpec
fixedData, err := json.Marshal(raw)
if err != nil {
return "", nil, false
}
// verify the fix actually round-trips before writing it.
if err := json.Unmarshal(fixedData, new(fixData)); err != nil {
return "", nil, false
}
return string(fixedData), blanked, true
}

View File

@@ -1,152 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
// panelTypeToRequestType mirrors savedviewtypes.LegacyRequestTypeForPanelType.
var panelTypeToRequestType = map[string]string{
"list": "raw",
"trace": "trace",
"graph": "time_series",
}
// storableSavedViewRow is the shape of the `saved_view` table this migration repairs.
type storableSavedViewRow struct {
bun.BaseModel `bun:"table:saved_view"`
ID string `bun:"id,pk,type:text"`
Data string `bun:"data,type:text"`
}
// viewSpec mirrors savedviewtypes.SavedViewSpec, used only to verify the fix round-trips.
type viewSpec struct {
DisplayName string `json:"displayName"`
PanelType string `json:"panelType"`
RequestType string `json:"requestType"`
Queries json.RawMessage `json:"queries"`
SelectedFields json.RawMessage `json:"selectedFields"`
Display json.RawMessage `json:"display"`
}
type viewData struct {
SchemaVersion string `json:"schemaVersion"`
Spec viewSpec `json:"spec"`
}
type savedViewRequestType struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewBackfillSavedViewRequestTypeFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("backfill_view_request_type"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &savedViewRequestType{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *savedViewRequestType) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *savedViewRequestType) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableSavedViewRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var migrated, skipped int
for _, row := range rows {
fixedData, ok := backfillSavedViewRequestType(row.Data)
if !ok {
migration.settings.Logger.WarnContext(ctx, "saved view data could not be repaired, leaving it untouched", slog.String("saved_view_id", row.ID), slog.String("raw_data", row.Data))
skipped++
continue
}
if fixedData == "" {
// already has a requestType -- nothing to do.
continue
}
migrated++
if _, err := tx.NewUpdate().Model((*storableSavedViewRow)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "backfilled saved view requestType from panelType", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
return tx.Commit()
}
func (migration *savedViewRequestType) Down(context.Context, *bun.DB) error {
return nil
}
// backfillSavedViewRequestType sets spec.requestType from spec.panelType when absent, leaving
// panelType where it already is. Returns ok=false if data can't be parsed at all, and fixed="" if
// there's nothing to do (requestType already set).
func backfillSavedViewRequestType(data string) (fixed string, ok bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "", false
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return "", false
}
if requestTypeRaw, ok := spec["requestType"]; ok && string(requestTypeRaw) != `""` {
return "", true
}
var panelType string
if panelTypeRaw, ok := spec["panelType"]; ok {
if err := json.Unmarshal(panelTypeRaw, &panelType); err != nil {
return "", false
}
}
requestType, known := panelTypeToRequestType[panelType]
if !known {
requestType = "scalar"
}
requestTypeJSON, err := json.Marshal(requestType)
if err != nil {
return "", false
}
spec["requestType"] = requestTypeJSON
fixedSpec, err := json.Marshal(spec)
if err != nil {
return "", false
}
raw["spec"] = fixedSpec
fixedData, err := json.Marshal(raw)
if err != nil {
return "", false
}
// verify the fix actually round-trips before writing it.
if err := json.Unmarshal(fixedData, new(viewData)); err != nil {
return "", false
}
return string(fixedData), true
}

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -19,7 +18,6 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
qbvariables "github.com/SigNoz/signoz/pkg/variables"
"github.com/huandu/go-sqlbuilder"
)
@@ -115,6 +113,8 @@ func (b *scopedTraceStatementBuilder) Build(
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
case qbtypes.RequestTypeRaw:
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
case qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries:
return b.buildAggregation(ctx, orgID, start, end, requestType, query, variables)
default:
return nil, ErrUnsupportedRequestType
}
@@ -143,6 +143,62 @@ func (b *scopedTraceStatementBuilder) buildDelegated(
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
}
// traceScopedStatementBuilder is the delegate's optional capability of constraining a
// query to a set of trace ids (implemented by the traces statement builder).
type traceScopedStatementBuilder interface {
BuildTraceScoped(ctx context.Context, orgID valuer.UUID, start, end uint64, requestType qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], variables map[string]qbtypes.VariableItem, traceScope *qbtypes.Statement) (*qbtypes.Statement, error)
}
// buildDelegatedAggregation serves span-level scalar/time-series: the gate is ANDed
// into the filter's span-level part and the query delegates to the standard trace
// builder; a trace-level part becomes a qualification the delegate constrains
// trace_id by.
func (b *scopedTraceStatementBuilder) buildDelegatedAggregation(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var spanExpr, traceExpr string
var err error
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
spanExpr, traceExpr, err = querybuilder.SplitFilterForAggregates(query.Filter.Expression, b.aggregateAliasSet())
if err != nil {
return nil, err
}
}
gate := b.scope.FilterExpression
expr := gate
if strings.TrimSpace(spanExpr) != "" {
expr = fmt.Sprintf("(%s) AND (%s)", gate, spanExpr)
}
// shallow copy; only Filter is replaced, caller's query untouched
gated := query
gated.Filter = &qbtypes.Filter{Expression: expr}
if strings.TrimSpace(traceExpr) == "" {
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
}
scoped, ok := b.traceStmtBuilder.(traceScopedStatementBuilder)
if !ok {
return nil, errors.NewInternalf(errors.CodeInternal, "trace statement builder does not support trace-scoped queries")
}
scope, err := b.buildQualifiedStatement(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), traceExpr, query, variables)
if err != nil {
return nil, err
}
if scope == nil {
// every trace-level condition was dropped by variable resolution
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
}
return scoped.BuildTraceScoped(ctx, orgID, start, end, requestType, gated, variables, scope)
}
// buildTraceListQuery wires the CTE pipeline (start/end are nanoseconds):
// matched (windowed, mask-pruned top-N trace_ids) → ranked (their [start,end] from
// the summary table) → buckets (ts_bucket_start prune) → enrichment (every per-trace
@@ -184,22 +240,17 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
if err != nil {
return nil, err
}
orderableSet := orderableAliasSet(resolved)
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
if err != nil {
return nil, err
}
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), orderableSet, start, end, variables, matchedSB)
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), start, end, variables, matchedSB)
if err != nil {
return nil, err
}
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, fp, resourcePred, limit, query.Offset)
if err != nil {
return nil, err
}
matchedFrag, matchedArgs := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, maskExpr, fp, resourcePred, limit, query.Offset)
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
@@ -380,27 +431,27 @@ func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy,
return orders, nil
}
// filterParts is the user filter split into a span-level predicate and a trace-level
// HAVING expression.
// filterParts is the user filter split into a span-level predicate and the resolved
// trace-level HAVING (nil when there is none).
type filterParts struct {
spanPred string
hasSpanFilter bool
havingExpr string
having *traceHaving
warnings []string
warningsURL string
}
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
// and a trace-level HAVING (explicit query.Having ANDed on), then validates the
// trace-level part against the matched-pass aggregates.
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
// splitFilter splits query.Filter into a span-level predicate and a trace-level
// HAVING (explicit query.Having ANDed on before resolution); args bind into sb.
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
var fp filterParts
havingExpr := ""
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
if err != nil {
return fp, err
}
fp.havingExpr = traceExpr
havingExpr = traceExpr
if strings.TrimSpace(spanExpr) != "" {
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sb)
if err != nil {
@@ -415,23 +466,17 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID val
}
}
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
if fp.havingExpr != "" {
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
if havingExpr != "" {
havingExpr = fmt.Sprintf("(%s) AND (%s)", havingExpr, query.Having.Expression)
} else {
fp.havingExpr = query.Having.Expression
havingExpr = query.Having.Expression
}
}
// the HAVING is a plain text rewrite, so substitute variables here
if strings.TrimSpace(fp.havingExpr) != "" && len(variables) > 0 {
replaced, err := qbvariables.ReplaceVariablesInExpression(fp.havingExpr, variables)
if err != nil {
return fp, err
}
fp.havingExpr = replaced
}
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
having, err := b.resolveTraceHaving(ctx, havingExpr, variables, sb)
if err != nil {
return fp, err
}
fp.having = having
return fp, nil
}
@@ -473,8 +518,8 @@ func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context,
// span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the aliases ORDER BY
// / HAVING reference. Expressions carry $n markers bound to sb, so each can appear
// several times and every occurrence resolves to the same arg.
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any) {
needed := neededMatchedAliases(orders, fp.having)
selects := []string{"trace_id"}
for _, rc := range resolved {
if _, ok := needed[rc.alias]; !ok {
@@ -511,22 +556,8 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
having = append(having, "countIf("+maskExpr+") > 0")
having = append(having, "countIf("+fp.spanPred+") > 0")
}
if strings.TrimSpace(fp.havingExpr) != "" {
// the rewriter matches raw key text, so map the trace. form alongside the bare name
columnMap := make(map[string]string, len(orderableSet)*2)
for a := range orderableSet {
columnMap[a] = quoteAlias(a)
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
}
hv, err := querybuilder.NewHavingExpressionRewriter().Rewrite(fp.havingExpr, columnMap)
if err != nil {
return "", nil, err
}
if hv != "" {
// escape user text so a literal $ isn't read as an arg marker; the countIf
// entries hold live $n markers and must stay unescaped
having = append(having, sqlbuilder.Escape(hv))
}
if fp.having != nil {
having = append(having, fp.having.pred)
}
if len(having) > 0 {
sb.Having(strings.Join(having, " AND "))
@@ -539,7 +570,7 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
}
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return fmt.Sprintf("matched AS (%s)", sql), args, nil
return fmt.Sprintf("matched AS (%s)", sql), args
}
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace from the
@@ -580,8 +611,9 @@ func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(sb *sqlbuilder.Selec
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
// aggregateAliasSet is every trace-level column alias, used to classify filter keys;
// SpanLevel columns are filtered span-level, so skip them.
// aggregateAliasSet recognises trace-level keys, display-only aliases included so one gets
// a targeted error rather than falling through as a span attribute; orderableColumnSet is
// what a predicate may actually use. SpanLevel columns are filtered span-level, so skip them.
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
set := make(map[string]struct{}, len(b.scope.Columns))
for _, c := range b.scope.Columns {
@@ -592,59 +624,36 @@ func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
return set
}
// orderableAliasSet is the subset of aliases computable in the matched pass.
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
set := make(map[string]struct{})
for _, rc := range resolved {
if rc.orderable {
set[rc.alias] = struct{}{}
}
}
return set
}
// neededMatchedAliases is the minimal alias set the matched pass must select: those
// in ORDER BY plus those in the aggregate HAVING.
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
// in ORDER BY plus those the resolved trace-level HAVING touches.
func neededMatchedAliases(orders []listOrder, having *traceHaving) map[string]struct{} {
needed := make(map[string]struct{})
for _, o := range orders {
needed[o.alias] = struct{}{}
}
for _, name := range traceAggregateNames(havingExpr) {
if _, ok := orderableSet[name]; ok {
if having != nil {
for name := range having.used {
needed[name] = struct{}{}
}
}
return needed
}
// traceAggregateNames extracts the aggregate names a trace-level HAVING references;
// only unspecified- and trace-context selectors name aggregates.
func traceAggregateNames(havingExpr string) []string {
var names []string
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
if sel.FieldContext == telemetrytypes.FieldContextUnspecified || sel.FieldContext == telemetrytypes.FieldContextTrace {
names = append(names, sel.Name)
}
}
return names
}
// validateAggregateFilter rejects a trace-level filter referencing an aggregate not
// computable in the matched pass.
// validateAggregateFilter rejects trace-level filters on aggregates not computable in
// the matched pass (e.g. span_count) with a targeted top-level error; inside the
// where-clause visitor it would surface only as a detail of a combined error. Only
// unspecified- and trace-context selectors name aggregates.
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
if strings.TrimSpace(havingExpr) == "" {
return nil
}
allowed := make([]string, 0, len(orderableSet))
for a := range orderableSet {
allowed = append(allowed, a)
}
sort.Strings(allowed)
for _, name := range traceAggregateNames(havingExpr) {
if _, ok := orderableSet[name]; !ok {
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != telemetrytypes.FieldContextTrace {
continue
}
if _, ok := orderableSet[sel.Name]; !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
"aggregate %q cannot be used in a trace-level filter; filterable aggregates: %s", sel.Name, strings.Join(sortedAliases(orderableSet), ", "))
}
}
return nil

View File

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

View File

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

View File

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

View File

@@ -32,6 +32,9 @@ type traceQueryStatementBuilder struct {
resourceFilterResolver *resourcefilter.ResourceFingerprintResolver[qbtypes.TraceAggregation]
aggExprRewriter qbtypes.AggExprRewriter
skipResourceFingerprintEnabled bool
// traceScope, set only on the per-call copy made by BuildTraceScoped, constrains
// queries to spans whose trace_id is in the __trace_scope CTE.
traceScope *qbtypes.Statement
}
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
@@ -95,6 +98,33 @@ func NewTraceQueryStatementBuilder(
}
}
// BuildTraceScoped is Build constrained to trace_ids selected by traceScope; the
// receiver is copied so the shared builder stays stateless.
func (b *traceQueryStatementBuilder) BuildTraceScoped(
ctx context.Context,
orgID valuer.UUID,
start uint64,
end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
traceScope *qbtypes.Statement,
) (*qbtypes.Statement, error) {
scoped := *b
scoped.traceScope = traceScope
return scoped.Build(ctx, orgID, start, end, requestType, query, variables)
}
// attachTraceScope adds the trace-scope condition to sb and returns the CTE fragment
// + args to prepend; both empty when no scope is set.
func (b *traceQueryStatementBuilder) attachTraceScope(sb *sqlbuilder.SelectBuilder) (string, []any) {
if b.traceScope == nil {
return "", nil
}
sb.Where("trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)")
return fmt.Sprintf("__trace_scope AS (%s)", b.traceScope.Query), b.traceScope.Args
}
// Build builds a SQL query for traces based on the given parameters.
func (b *traceQueryStatementBuilder) Build(
ctx context.Context,
@@ -519,6 +549,11 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
cteArgs = append(cteArgs, args)
}
if scopeFrag, scopeArgs := b.attachTraceScope(sb); scopeFrag != "" {
cteFragments = append(cteFragments, scopeFrag)
cteArgs = append(cteArgs, scopeArgs)
}
sb.SelectMore(fmt.Sprintf(
"toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts",
int64(query.StepInterval.Seconds()),
@@ -679,6 +714,13 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
cteArgs = append(cteArgs, args)
}
// skipResourceCTE means this scalar is embedded as a CTE of a time-series query,
// which has already emitted the __trace_scope fragment — add only the condition.
if scopeFrag, scopeArgs := b.attachTraceScope(sb); scopeFrag != "" && !skipResourceCTE {
cteFragments = append(cteFragments, scopeFrag)
cteArgs = append(cteArgs, scopeArgs)
}
allAggChArgs := []any{}
fieldNames := make([]string, 0, len(query.GroupBy))

View File

@@ -201,18 +201,6 @@ func (storableDashboardData *StorableDashboardData) GetWidgetIds() []string {
return widgetIds
}
// ErrIfNotDeletable gates deletion on the columns alone, never on Data, so a
// dashboard whose data is corrupt or stuck on the v1 schema stays deletable.
func (storable StorableDashboard) ErrIfNotDeletable() error {
if storable.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
if !storable.Source.isUserDeletable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", storable.Source)
}
return nil
}
func (dashboard *Dashboard) ErrIfNotMutable() error {
if dashboard.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")

View File

@@ -4,7 +4,6 @@ import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
@@ -82,64 +81,3 @@ func TestCanUpdate_MultipleDeletions_ByDiff(t *testing.T) {
})
}
}
func TestStorableDashboardErrIfNotDeletable(t *testing.T) {
testCases := []struct {
subtestName string
locked bool
source Source
data StorableDashboardData
expectDeletable bool
}{
{
subtestName: "user dashboard on the v2 schema",
source: SourceUser,
data: StorableDashboardData{"metadata": map[string]any{"schemaVersion": SchemaVersion}},
expectDeletable: true,
},
{
subtestName: "user dashboard still on the v1 schema",
source: SourceUser,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: true,
},
{
subtestName: "user dashboard with unreadable data",
source: SourceUser,
data: StorableDashboardData{"metadata": "not-an-object"},
expectDeletable: true,
},
{
subtestName: "locked user dashboard",
locked: true,
source: SourceUser,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
{
subtestName: "system dashboard",
source: SourceSystem,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
{
subtestName: "integration dashboard",
source: SourceIntegration,
data: StorableDashboardData{"widgets": makeTestWidgets("a")},
expectDeletable: false,
},
}
for _, tc := range testCases {
t.Run(tc.subtestName, func(t *testing.T) {
storable := StorableDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
OrgID: valuer.GenerateUUID(),
Locked: tc.locked,
Source: tc.source,
Data: tc.data,
}
assert.Equal(t, tc.expectDeletable, storable.ErrIfNotDeletable() == nil)
})
}
}

View File

@@ -129,6 +129,16 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) ErrIfNotDeletable() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
if !d.Source.isUserDeletable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be deleted", d.Source)
}
return nil
}
func (d *DashboardV2) ErrIfNotClonable() error {
if !d.Source.isClonable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)

View File

@@ -7,8 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"k8s.io/apimachinery/pkg/util/validation"
@@ -30,76 +28,27 @@ var (
)
type SavedView struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `json:"-"`
Name string `json:"name"`
Source Source `json:"source"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
}
type StorableSavedView struct {
bun.BaseModel `bun:"table:saved_view"`
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID string `bun:"org_id,notnull"`
Name string `bun:"name,type:text,notnull"`
Source Source `bun:"source,type:text,notnull"`
Data SavedViewData `bun:"data,type:text,notnull"`
}
func (s *StorableSavedView) ToSavedView() *SavedView {
spec := s.Data.Spec
if spec.Queries == nil {
spec.Queries = []qbtypes.QueryEnvelope{}
}
if spec.SelectedFields == nil {
spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
}
return &SavedView{
Identifiable: s.Identifiable,
TimeAuditable: s.TimeAuditable,
UserAuditable: s.UserAuditable,
OrgID: s.OrgID,
Name: s.Name,
Source: s.Source,
SchemaVersion: SchemaVersion{valuer.NewString(s.Data.SchemaVersion)},
Spec: spec,
}
}
func NewStorableSavedView(view *SavedView) *StorableSavedView {
return &StorableSavedView{
Identifiable: view.Identifiable,
TimeAuditable: view.TimeAuditable,
UserAuditable: view.UserAuditable,
OrgID: view.OrgID,
Name: view.Name,
Source: view.Source,
Data: SavedViewData{
SchemaVersion: view.SchemaVersion.StringValue(),
Spec: view.Spec,
},
}
OrgID string `json:"-" bun:"org_id,notnull"`
Name string `json:"name" bun:"name,type:text,notnull"`
Source Source `json:"source" bun:"source,type:text,notnull"`
Data SavedViewData `json:"data" bun:"data,type:text,notnull"`
}
type PostableSavedView struct {
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Source Source `json:"source" required:"true"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
}
type UpdatableSavedView struct {
Source Source `json:"source" required:"true"`
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
Spec SavedViewSpec `json:"spec" required:"true"`
Source Source `json:"source" required:"true"`
Data SavedViewData `json:"data" required:"true"`
}
type ListSavedViewsParams struct {
@@ -134,7 +83,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
name := postable.Name
if postable.GenerateName {
name = generateSavedViewName(postable.Spec.DisplayName)
name = generateSavedViewName(postable.Data.Spec.DisplayName)
}
return &SavedView{
@@ -144,8 +93,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
OrgID: orgID,
Name: name,
Source: postable.Source,
SchemaVersion: postable.SchemaVersion,
Spec: postable.Spec,
Data: postable.Data,
}
}
@@ -158,8 +106,7 @@ func (updatable UpdatableSavedView) ToSavedView(id valuer.UUID, orgID string, up
UserAuditable: types.UserAuditable{UpdatedBy: updatedBy},
OrgID: orgID,
Source: updatable.Source,
SchemaVersion: updatable.SchemaVersion,
Spec: updatable.Spec,
Data: updatable.Data,
}
}
@@ -170,11 +117,8 @@ func (p *PostableSavedView) Validate() error {
if err := p.Source.Validate(); err != nil {
return err
}
if err := p.SchemaVersion.Validate(); err != nil {
return err
}
return p.Spec.Validate()
return p.Data.Validate()
}
func (p *PostableSavedView) validateName() error {
@@ -191,11 +135,8 @@ func (u *UpdatableSavedView) Validate() error {
if err := u.Source.Validate(); err != nil {
return err
}
if err := u.SchemaVersion.Validate(); err != nil {
return err
}
return u.Spec.Validate()
return u.Data.Validate()
}
func (p *ListSavedViewsParams) Validate() error {
@@ -206,17 +147,7 @@ func (p *ListSavedViewsParams) Validate() error {
return p.Source.Validate()
}
// NewSavedViewsFromStorableSavedViews converts scanned rows to their domain shape.
func NewSavedViewsFromStorableSavedViews(storableSavedViews []*StorableSavedView) []*SavedView {
savedViews := make([]*SavedView, len(storableSavedViews))
for idx, storableSavedView := range storableSavedViews {
savedViews[idx] = storableSavedView.ToSavedView()
}
return savedViews
}
func NewStatsFromStorableSavedViews(savedViews []*StorableSavedView) map[string]any {
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
stats := make(map[string]any)
for _, savedView := range savedViews {
key := "savedview.source." + strings.ToLower(savedView.Source.StringValue()) + ".count"

View File

@@ -4,28 +4,29 @@ import (
"strings"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/util/validation"
)
func validPostableSavedView() PostableSavedView {
return PostableSavedView{
Name: "my-view",
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
Name: "my-view",
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
}
}
func validUpdatableSavedView() UpdatableSavedView {
return UpdatableSavedView{
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
Source: SourceLogs,
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
},
}
}
@@ -68,7 +69,7 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("invalid saved view data is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.SchemaVersion = SchemaVersion{valuer.NewString("v1")}
view.Data.SchemaVersion = "v1"
assert.Error(t, view.Validate())
})
@@ -99,15 +100,9 @@ func TestPostableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Spec.DisplayName = ""
view.Data.Spec.DisplayName = ""
assert.ErrorContains(t, view.Validate(), "displayName is required")
})
t.Run("missing requestType is rejected", func(t *testing.T) {
view := validPostableSavedView()
view.Spec.RequestType = qbtypes.RequestType{}
assert.ErrorContains(t, view.Validate(), "requestType is required")
})
}
func TestUpdatableSavedViewValidate(t *testing.T) {
@@ -124,15 +119,9 @@ func TestUpdatableSavedViewValidate(t *testing.T) {
t.Run("empty displayName is rejected", func(t *testing.T) {
view := validUpdatableSavedView()
view.Spec.DisplayName = ""
view.Data.Spec.DisplayName = ""
assert.ErrorContains(t, view.Validate(), "displayName is required")
})
t.Run("missing requestType is rejected", func(t *testing.T) {
view := validUpdatableSavedView()
view.Spec.RequestType = qbtypes.RequestType{}
assert.ErrorContains(t, view.Validate(), "requestType is required")
})
}
func TestListSavedViewsParamsValidate(t *testing.T) {
@@ -164,8 +153,7 @@ func TestNewSavedView(t *testing.T) {
assert.Equal(t, "creator@signoz.io", savedView.UpdatedBy)
assert.Equal(t, view.Name, savedView.Name)
assert.Equal(t, view.Source, savedView.Source)
assert.Equal(t, view.SchemaVersion, savedView.SchemaVersion)
assert.Equal(t, view.Spec, savedView.Spec)
assert.Equal(t, view.Data, savedView.Data)
assert.False(t, savedView.CreatedAt.IsZero())
assert.Equal(t, savedView.CreatedAt, savedView.UpdatedAt)
}
@@ -175,14 +163,14 @@ func TestNewSavedView_GeneratesNameWhenEmpty(t *testing.T) {
view := validPostableSavedView()
view.Name = ""
view.GenerateName = true
view.Spec.DisplayName = "My View!"
view.Data.Spec.DisplayName = "My View!"
savedView := view.ToSavedView(orgID, "creator@signoz.io")
assert.NotEmpty(t, savedView.Name)
assert.Empty(t, validation.IsDNS1123Label(savedView.Name), "generated name must be a valid DNS-1123 label")
assert.True(t, strings.HasPrefix(savedView.Name, "my-view-"))
assert.Equal(t, "My View!", savedView.Spec.DisplayName)
assert.Equal(t, "My View!", savedView.Data.Spec.DisplayName)
}
func TestGenerateSavedViewName(t *testing.T) {
@@ -224,95 +212,17 @@ func TestGenerateSavedViewName(t *testing.T) {
})
}
func TestStorableSavedView_ToSavedView(t *testing.T) {
t.Run("round trip preserves populated fields", func(t *testing.T) {
view := &SavedView{
Name: "my-view",
Source: SourceLogs,
SchemaVersion: SavedViewSchemaVersion,
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
RequestType: qbtypes.RequestTypeTimeSeries,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
},
}
view.OrgID = valuer.GenerateUUID().StringValue()
roundTripped := NewStorableSavedView(view).ToSavedView()
assert.Equal(t, view.OrgID, roundTripped.OrgID)
assert.Equal(t, view.Name, roundTripped.Name)
assert.Equal(t, view.Source, roundTripped.Source)
assert.Equal(t, view.SchemaVersion, roundTripped.SchemaVersion)
assert.Equal(t, view.Spec, roundTripped.Spec)
})
t.Run("nil selectedFields normalizes to an empty slice, not nil", func(t *testing.T) {
storable := &StorableSavedView{
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion.StringValue(),
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: validQueries(),
SelectedFields: nil,
},
},
}
view := storable.ToSavedView()
assert.NotNil(t, view.Spec.SelectedFields)
assert.Empty(t, view.Spec.SelectedFields)
})
t.Run("nil queries normalizes to an empty slice, not nil", func(t *testing.T) {
storable := &StorableSavedView{
Data: SavedViewData{
SchemaVersion: SavedViewSchemaVersion.StringValue(),
Spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
Queries: nil,
},
},
}
view := storable.ToSavedView()
assert.NotNil(t, view.Spec.Queries)
assert.Empty(t, view.Spec.Queries)
})
}
func TestNewStatsFromStorableSavedViews(t *testing.T) {
storables := []*StorableSavedView{
func TestNewStatsFromSavedViews(t *testing.T) {
views := []*SavedView{
{Source: SourceLogs},
{Source: SourceLogs},
{Source: SourceTraces},
}
stats := NewStatsFromStorableSavedViews(storables)
stats := NewStatsFromSavedViews(views)
assert.Equal(t, int64(3), stats["savedview.count"])
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
assert.NotContains(t, stats, "savedview.source.metrics.count")
}
func TestNewSavedViewsFromStorableSavedViews(t *testing.T) {
storables := []*StorableSavedView{
{Name: "a", Source: SourceLogs, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "a", PanelType: PanelTypeGraph, Queries: validQueries()}}},
{Name: "b", Source: SourceTraces, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "b", PanelType: PanelTypeTable, Queries: validQueries()}}},
}
views := NewSavedViewsFromStorableSavedViews(storables)
require.Len(t, views, 2)
assert.Equal(t, "a", views[0].Name)
assert.Equal(t, SourceLogs, views[0].Source)
assert.Equal(t, "b", views[1].Name)
assert.Equal(t, SourceTraces, views[1].Source)
}

View File

@@ -28,7 +28,7 @@ func (t *StoreTest) Store() savedviewtypes.Store { return t.store }
func (t *StoreTest) Mock() sqlmock.Sqlmock { return t.mock }
func savedViewRow(view *savedviewtypes.SavedView) []driver.Value {
data, _ := json.Marshal(savedviewtypes.NewStorableSavedView(view).Data)
data, _ := json.Marshal(view.Data)
return []driver.Value{
view.ID.StringValue(),
view.CreatedAt,
@@ -47,12 +47,6 @@ func (t *StoreTest) ExpectCreate() {
t.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnResult(sqlmock.NewResult(1, 1))
}
// ExpectCreateError sets up the SQL expectation for a Create call whose insert
// fails, e.g. on a UNIQUE(org_id, name) violation.
func (t *StoreTest) ExpectCreateError(err error) {
t.mock.ExpectExec(`INSERT INTO "saved_view"`).WillReturnError(err)
}
// ExpectGet sets up the SQL expectation for a Get call. Pass view = nil to
// simulate a not-found row.
func (t *StoreTest) ExpectGet(orgID string, id valuer.UUID, view *savedviewtypes.SavedView) {

View File

@@ -8,7 +8,7 @@ import (
)
// SavedViewSchemaVersion is the only schemaVersion currently.
var SavedViewSchemaVersion = SchemaVersion{valuer.NewString("v2")}
const SavedViewSchemaVersion = "v2"
var (
PanelTypeValue = PanelType{valuer.NewString("value")}
@@ -30,10 +30,9 @@ type Display struct {
type SavedViewSpec struct {
DisplayName string `json:"displayName" required:"true"`
PanelType PanelType `json:"panelType" required:"true"`
RequestType qbtypes.RequestType `json:"requestType" required:"true"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false" minItems:"1"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" nullable:"false"`
Display Display `json:"display"`
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
Display Display `json:"display" required:"true"`
}
// SavedViewData is what's persisted as saved view data.
@@ -42,11 +41,6 @@ type SavedViewData struct {
Spec SavedViewSpec `json:"spec" required:"true"`
}
// SchemaVersion has v2 as the only value currently.
type SchemaVersion struct {
valuer.String
}
// PanelType is the explore-page panel a saved view renders as.
type PanelType struct {
valuer.String
@@ -71,17 +65,6 @@ func (p PanelType) Validate() error {
}
}
func (SchemaVersion) Enum() []any {
return []any{SavedViewSchemaVersion}
}
func (s SchemaVersion) Validate() error {
if s != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion.StringValue(), s.StringValue())
}
return nil
}
func (s *SavedViewSpec) Validate() error {
if s.DisplayName == "" {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "displayName is required")
@@ -89,23 +72,14 @@ func (s *SavedViewSpec) Validate() error {
if err := s.PanelType.Validate(); err != nil {
return err
}
if s.RequestType.IsZero() {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "requestType is required")
}
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate(qbtypes.GetValidationOptions(s.RequestType)...)
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
}
// LegacyRequestTypeForPanelType exists only for the v1 legacy API.
func LegacyRequestTypeForPanelType(p PanelType) qbtypes.RequestType {
switch p {
case PanelTypeList:
return qbtypes.RequestTypeRaw
case PanelTypeTrace:
return qbtypes.RequestTypeTrace
case PanelTypeGraph:
return qbtypes.RequestTypeTimeSeries
default:
return qbtypes.RequestTypeScalar
func (d *SavedViewData) Validate() error {
if d.SchemaVersion != SavedViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
}
return d.Spec.Validate()
}

View File

@@ -1,15 +1,12 @@
package savedviewtypes
import (
"encoding/json"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validQueries() []qbtypes.QueryEnvelope {
@@ -59,124 +56,35 @@ func TestSavedViewSpecValidate(t *testing.T) {
}{
{
name: "valid spec",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
expectError: false,
},
{
name: "empty display name is rejected",
spec: SavedViewSpec{RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
expectError: true,
},
{
name: "invalid panel type is rejected",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelType{valuer.NewString("bogus")}, RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
expectError: true,
},
{
name: "unset panel type is rejected",
spec: SavedViewSpec{DisplayName: "My View", RequestType: qbtypes.RequestTypeTimeSeries, Queries: validQueries()},
expectError: true,
},
{
name: "missing requestType is rejected",
spec: SavedViewSpec{DisplayName: "My View", Queries: validQueries()},
name: "invalid panel type is rejected before queries are checked",
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelType{valuer.NewString("bogus")}, Queries: validQueries()},
expectError: true,
},
{
name: "no queries is rejected",
spec: SavedViewSpec{DisplayName: "My View", RequestType: qbtypes.RequestTypeTimeSeries},
spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph},
expectError: true,
},
{
name: "selectedFields and display populated is still valid",
name: "selected fields and display are not required",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
Display: Display{MaxLines: 3, FontSize: "small", Format: "table", Color: "blue"},
},
expectError: false,
},
{
name: "nil selectedFields is valid -- selectedFields itself is not required",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeValue,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: nil,
},
expectError: false,
},
{
name: "empty (non-nil) selectedFields is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeValue,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
},
expectError: false,
},
{
name: "zero-value display is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeValue,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
Display: Display{},
},
expectError: false,
},
{
name: "list panel query with no aggregation is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeList,
RequestType: qbtypes.RequestTypeRaw,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: false,
},
{
name: "trace panel query with no aggregation is valid",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTrace,
RequestType: qbtypes.RequestTypeTrace,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: false,
},
{
name: "graph panel query with no aggregation is still rejected",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
RequestType: qbtypes.RequestTypeTimeSeries,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
},
expectError: true,
},
}
for _, c := range cases {
@@ -191,65 +99,37 @@ func TestSavedViewSpecValidate(t *testing.T) {
}
}
func TestSavedViewSpecValidate_RequestTypeIsIndependentOfPanelType(t *testing.T) {
// RequestType, not PanelType, governs which aggregation rules apply -- nothing
// derives one from the other inside Validate.
spec := SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeGraph,
RequestType: qbtypes.RequestTypeRaw,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
},
}},
}
assert.NoError(t, spec.Validate())
spec.RequestType = qbtypes.RequestTypeTimeSeries
assert.Error(t, spec.Validate())
}
func TestSavedViewSpecJSONUnmarshal_OptionalFields(t *testing.T) {
base := `"displayName":"My View","panelType":"value","requestType":"scalar","queries":[{"type":"builder_query","spec":{"signal":"logs","aggregations":[{"expression":"count()"}]}}]`
func TestSavedViewDataValidate(t *testing.T) {
cases := []struct {
name string
json string
name string
data SavedViewData
expectError bool
}{
{name: "selectedFields and display omitted entirely", json: `{` + base + `}`},
{name: "selectedFields and display explicitly null", json: `{` + base + `,"selectedFields":null,"display":null}`},
{name: "selectedFields empty array, display empty object", json: `{` + base + `,"selectedFields":[],"display":{}}`},
{
name: "valid data",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: false,
},
{
name: "wrong schema version is rejected",
data: SavedViewData{SchemaVersion: "v1", Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
},
{
name: "empty schema version is rejected",
data: SavedViewData{Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
expectError: true,
},
{
name: "invalid spec is rejected",
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph}},
expectError: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var spec SavedViewSpec
err := json.Unmarshal([]byte(c.json), &spec)
require.NoError(t, err)
assert.NoError(t, spec.Validate())
assert.Empty(t, spec.SelectedFields)
assert.Equal(t, Display{}, spec.Display)
})
}
}
func TestSchemaVersionValidate(t *testing.T) {
cases := []struct {
name string
schemaVersion SchemaVersion
expectError bool
}{
{name: "valid schema version", schemaVersion: SavedViewSchemaVersion, expectError: false},
{name: "wrong schema version is rejected", schemaVersion: SchemaVersion{valuer.NewString("v1")}, expectError: true},
{name: "empty schema version is rejected", schemaVersion: SchemaVersion{}, expectError: true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.schemaVersion.Validate()
err := c.data.Validate()
if c.expectError {
assert.Error(t, err)
} else {

View File

@@ -7,9 +7,9 @@ import (
)
type Store interface {
Create(ctx context.Context, view *StorableSavedView) error
Get(ctx context.Context, orgID string, id valuer.UUID) (*StorableSavedView, error)
Update(ctx context.Context, view *StorableSavedView) error
Create(ctx context.Context, view *SavedView) error
Get(ctx context.Context, orgID string, id valuer.UUID) (*SavedView, error)
Update(ctx context.Context, view *SavedView) error
Delete(ctx context.Context, orgID string, id valuer.UUID) error
List(ctx context.Context, orgID string, source Source, name string) ([]*StorableSavedView, error)
List(ctx context.Context, orgID string, source Source, name string) ([]*SavedView, error)
}

View File

@@ -1,78 +0,0 @@
package telemetrytypes
import "strings"
// LogicalField is one queryable field. Its Name is the spelling that the
// request used. Its Members are the physical keys that store the field.
// LogicalField is the output type of name resolution: resolution changes a
// referenced name into logical fields, and compilers make SQL from them.
//
// A []*LogicalField shows ambiguity. Ambiguity means that possibly different
// fields have the same name. Each logical field in the slice gets its own
// condition. The operator tells the compiler how to connect the conditions.
//
// One LogicalField with more than one member shows a semantic-convention
// family. A family is one field that has more than one spelling. The members
// are in current-first order. The compiler merges the members into one
// expression, and the current name wins.
//
// Members always has one entry or more. A field that is not a family has
// exactly one member. The members point to the metadata map entries. Do not
// change the members.
type LogicalField struct {
// Name is the spelling that the request used. Aliases, series labels,
// and warnings use this spelling. Because of this, the response shows
// the same spelling as the request.
Name string
// Signal, FieldContext, and FieldDataType are the identity that all
// members share. Members with a different signal, field context, or
// data type are parts of different logical fields.
Signal Signal
FieldContext FieldContext
FieldDataType FieldDataType
// Members are the physical keys that store this field, in current-first
// order. Each member has its own physical data (Materialized,
// Evolutions, JSONPlan, ...). A per-member accessor does not need data
// from the other members.
Members []*TelemetryFieldKey
}
// SingleLogicalField makes a logical field that has one physical key.
func SingleLogicalField(name string, key *TelemetryFieldKey) *LogicalField {
return &LogicalField{
Name: name,
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldDataType: key.FieldDataType,
Members: []*TelemetryFieldKey{key},
}
}
// Single returns the only member of a single-member field. A decision that
// uses only the shared identity can also use Single on a family. This is
// safe because all members have the same signal, context, and data type.
func (l *LogicalField) Single() *TelemetryFieldKey {
return l.Members[0]
}
// IsFamily returns true when the field has more than one physical member.
func (l *LogicalField) IsFamily() bool {
return len(l.Members) > 1
}
// String implements fmt.Stringer. A single-member field prints as its
// member. Because of this, a message made from the field and a message made
// from the key are the same. A family prints its shared identity and its
// member spellings.
func (l *LogicalField) String() string {
if len(l.Members) == 1 {
return l.Members[0].String()
}
names := make([]string, 0, len(l.Members))
for _, member := range l.Members {
names = append(names, member.Name)
}
return l.Name + "(" + l.FieldContext.StringValue() + ", " + l.FieldDataType.StringValue() + ", members: " + strings.Join(names, ", ") + ")"
}

View File

@@ -1,50 +0,0 @@
package telemetrytypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSingleLogicalFieldSharesIdentityAndAliasesKey(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
logical := SingleLogicalField("resource.service.name", key)
assert.Equal(t, "resource.service.name", logical.Name, "the identity is the spelling that the request used, not the stored spelling")
assert.Equal(t, key.Signal, logical.Signal)
assert.Equal(t, key.FieldContext, logical.FieldContext)
assert.Equal(t, key.FieldDataType, logical.FieldDataType)
assert.False(t, logical.IsFamily())
assert.Same(t, key, logical.Single(), "the member points to the key; there is no copy")
}
func TestStringDelegatesForSingleMember(t *testing.T) {
key := &TelemetryFieldKey{
Name: "service.name",
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
}
assert.Equal(t, key.String(), SingleLogicalField(key.Name, key).String(),
"a message made from a single-member field must be the same as a message made from the key")
}
func TestStringListsFamilyMembers(t *testing.T) {
logical := &LogicalField{
Name: "deployment.environment.name",
Signal: SignalTraces,
FieldContext: FieldContextResource,
FieldDataType: FieldDataTypeString,
Members: []*TelemetryFieldKey{
{Name: "deployment.environment.name"},
{Name: "deployment.environment"},
},
}
assert.True(t, logical.IsFamily())
assert.Equal(t, "deployment.environment.name(resource, string, members: deployment.environment.name, deployment.environment)", logical.String())
}

View File

@@ -26,14 +26,14 @@ class ProviderAccountSpec:
provider: str
# params for the account created by default.
initial_params: dict
# params for the config an update (PUT) test sends.
updated_params: dict
# params -> the provider-keyed `config` block for a POST/PUT body.
build_config: Callable[[dict], dict]
# params -> the full config block the API is expected to return under
# config[provider] on GET/list. This may differ from what build_config sends:
# e.g. AWS accepts deploymentRegion on POST but the API does not echo it back.
expected_config: Callable[[dict], dict]
# only the suites that exercise updates need to supply it.
updated_params: dict = field(default_factory=dict)
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
@@ -42,29 +42,6 @@ class ProviderAccountSpec:
object.__setattr__(self, "id", self.provider)
# Per-provider service shape.
@dataclass(frozen=True)
class ProviderServiceSpec:
provider: str
service_id: str
# GCP ships every service with supportedSignals.logs false, so a logs block
# is neither required on write nor persisted.
supports_logs: bool
account_config: dict
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
def __post_init__(self) -> None:
if not self.id:
object.__setattr__(self, "id", self.provider)
def build_service_config(self, metrics_enabled: bool, logs_enabled: bool | None = None) -> dict:
config: dict = {"metrics": {"enabled": metrics_enabled}}
if self.supports_logs:
config["logs"] = {"enabled": metrics_enabled if logs_enabled is None else logs_enabled}
return {self.provider: config}
@pytest.fixture(scope="function")
def deprecated_create_cloud_integration_account(
request: pytest.FixtureRequest,

View File

@@ -32,10 +32,10 @@ def ai_trace(
*,
now: datetime,
service: str,
user: str,
in_tokens: int | None,
out_tokens: int,
cost: float,
user: str = "user",
cost: float = 0.1,
model: str = "gpt-4o-mini",
environment: str = "production",
) -> list[Traces]:
@@ -74,6 +74,28 @@ def ai_trace(
]
def tool_only_trace(*, now: datetime, service: str) -> list[Traces]:
"""Root + one tool span: passes the gen_ai gate but has NO LLM span."""
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
resources = {"service.name": service}
return [
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=2),
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=0.5),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="execute_tool",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"gen_ai.tool.name": "get_weather", "gen_ai.tool.type": "function"},
),
]
def ai_trace_mixed_spans(*, now: datetime, service: str, user: str) -> list[Traces]:
"""Root + LLM + tool + agent spans; only the LLM span carries gen_ai.request.model."""
trace_id = TraceIdGenerator.trace_id()

View File

@@ -13,14 +13,15 @@ def _body(name: str, source: str = "logs") -> dict:
return {
"name": name,
"source": source,
"schemaVersion": "v2",
"spec": {
"displayName": name,
"panelType": "table",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": name,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,14 @@
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import ProviderAccountSpec
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1", "us-west-2"]},
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
def test_apply_license(
signoz: types.SigNoz,
@@ -58,19 +20,19 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_create_account(
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
cloud_provider = "aws"
data = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
cloud_provider,
deployment_region="us-east-1",
regions=["us-east-1", "us-west-2"],
)
assert "id" in data, "Response data should contain 'id' field"
@@ -78,17 +40,12 @@ def test_create_account(
assert "connectionArtifact" in data, "Response data should contain 'connectionArtifact' field"
artifact = data["connectionArtifact"]
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
if spec.provider == "aws":
assert "aws" in artifact, "connectionArtifact should contain 'aws' field"
assert "connectionUrl" in artifact["aws"], "connectionArtifact.aws should contain 'connectionUrl'"
connection_url = artifact["aws"]["connectionUrl"]
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
assert f"region={spec.initial_params['deployment_region']}" in connection_url, "connectionUrl should contain the deployment region"
else:
# GCP is a manual flow: no one-click install artifact.
assert artifact.get("gcp") is None, f"GCP should not return a connection artifact, got: {artifact}"
connection_url = artifact["aws"]["connectionUrl"]
assert "console.aws.amazon.com/cloudformation" in connection_url, "connectionUrl should be an AWS CloudFormation URL"
assert "region=us-east-1" in connection_url, "connectionUrl should contain the deployment region"
def test_create_account_unsupported_provider(
@@ -119,36 +76,3 @@ def test_create_account_unsupported_provider(
response_data = response.json()
assert "error" in response_data, "Response should contain 'error' field"
def test_create_gcp_account_without_project_ids(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""GCP account config requires at least one project ID to monitor."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/cloud_integrations/gcp/accounts"),
headers={"Authorization": f"Bearer {admin_token}"},
json={
"config": {
"gcp": {
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": [],
}
},
"credentials": {
"sigNozApiURL": "https://test.signoz.cloud",
"sigNozApiKey": "test-key",
"ingestionUrl": "https://ingest.test.signoz.cloud",
"ingestionKey": "test-ingestion-key",
},
},
timeout=10,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 for empty projectIds, got {response.status_code}: {response.text}"
assert "error" in response.json(), "Response should contain 'error' field"

View File

@@ -2,53 +2,14 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import (
ProviderAccountSpec,
simulate_agent_checkin,
)
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
AWS_ACCOUNT_SPEC = ProviderAccountSpec(
provider="aws",
initial_params={"deployment_region": "us-east-1", "regions": ["us-east-1"]},
build_config=lambda p: {"aws": {"deploymentRegion": p["deployment_region"], "regions": p["regions"]}},
expected_config=lambda p: {"regions": p["regions"]},
)
GCP_ACCOUNT_SPEC = ProviderAccountSpec(
provider="gcp",
initial_params={
"deployment_project_id": "signoz-test-project",
"deployment_region": "us-central1",
"project_ids": ["signoz-test-project"],
},
build_config=lambda p: {
"gcp": {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
}
},
expected_config=lambda p: {
"deploymentProjectId": p["deployment_project_id"],
"deploymentRegion": p["deployment_region"],
"projectIds": p["project_ids"],
},
)
PROVIDER_ACCOUNT_SPECS = [AWS_ACCOUNT_SPEC, GCP_ACCOUNT_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_ACCOUNT_SPECS,
ids=[s.id for s in PROVIDER_ACCOUNT_SPECS],
)
CLOUD_PROVIDER = "aws"
def test_apply_license(
@@ -61,28 +22,22 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_agent_check_in(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(
admin_token,
spec.provider,
config=spec.build_config(spec.initial_params),
)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER, regions=["us-east-1"])
account_id = account["id"]
provider_account_id = str(uuid.uuid4())
response = simulate_agent_checkin(
signoz,
admin_token,
spec.provider,
CLOUD_PROVIDER,
account_id,
provider_account_id,
data={"version": "v0.0.8"},
@@ -92,63 +47,57 @@ def test_agent_check_in(
data = response.json()["data"]
# New camelCase fields
assert data["cloudIntegrationId"] == account_id, "cloudIntegrationId should match"
assert data["providerAccountId"] == provider_account_id, "providerAccountId should match"
assert "integrationConfig" in data, "Response should contain 'integrationConfig'"
assert data["removedAt"] is None, "removedAt should be null for a live account"
if spec.provider == "aws":
# Backward compat for agents deployed before the camelCase response; AWS only.
assert data["account_id"] == account_id, "account_id (compat) should match"
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
# Backward-compat snake_case fields
assert data["account_id"] == account_id, "account_id (compat) should match"
assert data["cloud_account_id"] == provider_account_id, "cloud_account_id (compat) should match"
assert "integration_config" in data, "Response should contain 'integration_config' (compat)"
assert "removed_at" in data, "Response should contain 'removed_at' (compat)"
integration_config = data["integrationConfig"]
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
assert integration_config["aws"]["enabledRegions"] == spec.initial_params["regions"], "enabledRegions should match account config"
else:
# GCP is a manual flow: the agent carries its own configuration.
assert data["integrationConfig"].get("gcp") is None, f"GCP should not return an integration config, got: {data['integrationConfig']}"
# integrationConfig should reflect the configured regions
integration_config = data["integrationConfig"]
assert "aws" in integration_config, "integrationConfig should contain 'aws' block"
assert integration_config["aws"]["enabledRegions"] == ["us-east-1"], "enabledRegions should match account config"
@provider_spec
def test_agent_check_in_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderAccountSpec,
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
fake_id = str(uuid.uuid4())
response = simulate_agent_checkin(signoz, admin_token, spec.provider, fake_id, str(uuid.uuid4()))
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, fake_id, str(uuid.uuid4()))
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}: {response.text}"
@provider_spec
def test_duplicate_cloud_account_checkins(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderAccountSpec,
) -> None:
"""Test that two different accounts cannot check in with the same providerAccountId."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account1 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
account2 = create_cloud_integration_account(admin_token, spec.provider, config=spec.build_config(spec.initial_params))
account1 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account2 = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
assert account1["id"] != account2["id"], "Two accounts should have different IDs"
same_provider_account_id = str(uuid.uuid4())
# First check-in: account1 claims the provider account ID
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account1["id"], same_provider_account_id)
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account1["id"], same_provider_account_id)
assert response.status_code == HTTPStatus.OK, f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
# Second check-in: account2 tries to claim the same provider account ID → 409
response = simulate_agent_checkin(signoz, admin_token, spec.provider, account2["id"], same_provider_account_id)
response = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account2["id"], same_provider_account_id)
assert response.status_code == HTTPStatus.CONFLICT, f"Expected 409 for duplicate providerAccountId, got {response.status_code}: {response.text}"

View File

@@ -2,47 +2,18 @@ import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from sqlalchemy import bindparam, sql
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.cloudintegrations import (
ProviderServiceSpec,
simulate_agent_checkin,
)
from fixtures.cloudintegrations import simulate_agent_checkin
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
AWS_SERVICE_SPEC = ProviderServiceSpec(
provider="aws",
service_id="rds",
supports_logs=True,
account_config={"aws": {"deploymentRegion": "us-east-1", "regions": ["us-east-1"]}},
)
GCP_SERVICE_SPEC = ProviderServiceSpec(
provider="gcp",
service_id="cloudsql_postgres",
supports_logs=False,
account_config={
"gcp": {
"deploymentProjectId": "signoz-test-project",
"deploymentRegion": "us-central1",
"projectIds": ["signoz-test-project"],
}
},
)
PROVIDER_SERVICE_SPECS = [AWS_SERVICE_SPEC, GCP_SERVICE_SPEC]
provider_spec = pytest.mark.parametrize(
"spec",
PROVIDER_SERVICE_SPECS,
ids=[s.id for s in PROVIDER_SERVICE_SPECS],
)
CLOUD_PROVIDER = "aws"
SERVICE_ID = "rds"
def test_apply_license(
@@ -55,18 +26,16 @@ def test_apply_license(
add_license(signoz, make_http_mocks, get_token)
@provider_spec
def test_list_services_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""List the cloud provider's supported services"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -84,37 +53,35 @@ def test_list_services_without_account(
assert "icon" in service, "Service should have 'icon' field"
assert "enabled" in service, "Service should have 'enabled' field"
listed_ids = {s["id"] for s in data["services"]}
assert spec.service_id in listed_ids, f"'{spec.service_id}' should be listed for {spec.provider}"
EC2_SERVICE_ID = "ec2"
@provider_spec
def test_list_account_services(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""ListAccountServicesMetadata reflects enabled state per service."""
"""ListAccountServicesMetadata reflects enabled state after enabling a service."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{EC2_SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable {spec.service_id} failed: {put_response.status_code}: {put_response.text}"
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Enable ec2 failed: {put_response.status_code}: {put_response.text}"
list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -125,28 +92,21 @@ def test_list_account_services(
assert isinstance(data["services"], list), "services should be a list"
assert len(data["services"]) > 0, "services list should be non-empty"
enabled_service = next((s for s in data["services"] if s["id"] == spec.service_id), None)
assert enabled_service is not None, f"Service '{spec.service_id}' not found in services list"
assert enabled_service["enabled"] is True, f"Service should be enabled, got: {enabled_service['enabled']}"
# The listing must report state per service, not blanket-enable or echo the write.
untouched_service = next((s for s in data["services"] if s["id"] != spec.service_id), None)
assert untouched_service is not None, "Expected more than one service in the listing"
assert untouched_service["enabled"] is False, f"Service '{untouched_service['id']}' was never enabled, got: {untouched_service['enabled']}"
ec2_service = next((s for s in data["services"] if s["id"] == EC2_SERVICE_ID), None)
assert ec2_service is not None, f"EC2 service '{EC2_SERVICE_ID}' not found in services list"
assert ec2_service["enabled"] is True, f"EC2 service should be enabled, got: {ec2_service['enabled']}"
@provider_spec
def test_get_service_details_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""Get full service definition without specifying an account."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -154,36 +114,31 @@ def test_get_service_details_without_account(
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
assert "title" in data, "Service should have 'title'"
assert "overview" in data, "Service should have 'overview' (markdown)"
assert "assets" in data, "Service should have 'assets'"
assert isinstance(data["assets"]["dashboards"], list), "assets.dashboards should be a list"
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
assert data["supportedSignals"]["metrics"] is True, "metrics should be a supported signal"
assert data["supportedSignals"]["logs"] is spec.supports_logs, f"logs support should be {spec.supports_logs} for {spec.provider}"
@provider_spec
def test_get_account_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Get service for a specific account — all disabled by default."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -191,22 +146,20 @@ def test_get_account_service(
assert response.status_code == HTTPStatus.OK, f"Expected 200, got {response.status_code}"
data = response.json()["data"]
assert data["id"] == spec.service_id, f"id should be '{spec.service_id}'"
assert data["id"] == SERVICE_ID, f"id should be '{SERVICE_ID}'"
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null before any config is set"
@provider_spec
def test_get_service_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""Get a non-existent service ID returns 400 (invalid service ID is a bad request)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/services/non-existent-service"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/services/non-existent-service"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -214,34 +167,32 @@ def test_get_service_not_found(
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
@provider_spec
def test_update_service_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enable a service and verify the config is persisted via GET."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -250,39 +201,33 @@ def test_update_service_config(
data = get_response.json()["data"]
svc = data["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should be non-null after UpdateService"
assert svc["config"][spec.provider]["metrics"]["enabled"] is True, "metrics should be enabled"
assert svc["config"]["aws"]["metrics"]["enabled"] is True, "metrics should be enabled"
assert svc["config"]["aws"]["logs"]["enabled"] is True, "logs should be enabled"
assert svc["cloudIntegrationId"] == account_id, "cloudIntegrationId should match the account"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is True, "logs should be enabled"
else:
assert svc["config"][spec.provider].get("logs") is None, f"logs should not be stored for {spec.provider}, got: {svc['config'][spec.provider]}"
@provider_spec
def test_update_service_config_disable(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enable then disable a service — config change is persisted."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
# Enable
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": True}}}},
timeout=10,
)
assert r.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {r.status_code}: {r.text}"
@@ -291,13 +236,13 @@ def test_update_service_config_disable(
r = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(False)},
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
timeout=10,
)
assert r.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {r.status_code}: {r.text}"
get_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -305,57 +250,28 @@ def test_update_service_config_disable(
assert get_response.status_code == HTTPStatus.OK
svc = get_response.json()["data"]["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should still be present after disable"
assert svc["config"][spec.provider]["metrics"]["enabled"] is False, "metrics should be disabled"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is False, "logs should be disabled"
assert svc["config"]["aws"]["metrics"]["enabled"] is False, "metrics should be disabled"
assert svc["config"]["aws"]["logs"]["enabled"] is False, "logs should be disabled"
@provider_spec
def test_update_service_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
spec: ProviderServiceSpec,
) -> None:
"""PUT with a non-existent account UUID returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{uuid.uuid4()}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{uuid.uuid4()}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
timeout=10,
)
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
def test_update_gcp_service_without_metrics_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""GCP services support metrics only, so a config omitting metrics is rejected."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, "gcp", config=GCP_SERVICE_SPEC.account_config)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, "gcp", account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/gcp/accounts/{account_id}/services/{GCP_SERVICE_SPEC.service_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": {"gcp": {"logs": {"enabled": True}}}},
timeout=10,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400 when metrics config is missing, got {response.status_code}: {response.text}"
def test_list_services_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
@@ -373,32 +289,30 @@ def test_list_services_unsupported_provider(
assert response.status_code == HTTPStatus.BAD_REQUEST, f"Expected 400, got {response.status_code}"
@provider_spec
def test_list_services_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""List services for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -406,32 +320,30 @@ def test_list_services_account_removed(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_get_service_details_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Get service details for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -439,68 +351,64 @@ def test_get_service_details_account_removed(
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_update_service_account_removed(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""PUT service config for a deleted account returns 404."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
delete_response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert delete_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204 on delete, got {delete_response.status_code}"
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True)},
json={"config": {"aws": {"metrics": {"enabled": True}}}},
timeout=10,
)
assert response.status_code == HTTPStatus.NOT_FOUND, f"Expected 404, got {response.status_code}"
@provider_spec
def test_enable_metrics_provisions_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Enabling metrics provisions dashboards visible in GetService and present in the DB."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
put_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True, logs_enabled=False)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
timeout=10,
)
assert put_response.status_code == HTTPStatus.NO_CONTENT, f"Expected 204, got {put_response.status_code}: {put_response.text}"
# Assertion 1: GetService returns provisioned dashboard UUIDs
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -509,7 +417,7 @@ def test_enable_metrics_provisions_dashboards(
data = get_svc_response.json()["data"]
svc = data["cloudIntegrationService"]
assert svc is not None, "cloudIntegrationService should be non-null after enabling metrics"
assert svc["config"][spec.provider]["metrics"]["enabled"] is True
assert svc["config"]["aws"]["metrics"]["enabled"] is True
dashboards_in_service = data["assets"]["dashboards"]
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
@@ -537,37 +445,35 @@ def test_enable_metrics_provisions_dashboards(
assert provisioned_ids == db_ids, f"Dashboards {provisioned_ids - db_ids} are missing from the DB"
@provider_spec
def test_disable_metrics_deprovisions_dashboards(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
spec: ProviderServiceSpec,
) -> None:
"""Disabling metrics removes provisioned dashboards from both GetService and the dashboards list."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, spec.provider, config=spec.account_config)
account = create_cloud_integration_account(admin_token, CLOUD_PROVIDER)
account_id = account["id"]
checkin = simulate_agent_checkin(signoz, admin_token, spec.provider, account_id, str(uuid.uuid4()))
checkin = simulate_agent_checkin(signoz, admin_token, CLOUD_PROVIDER, account_id, str(uuid.uuid4()))
assert checkin.status_code == HTTPStatus.OK, f"Check-in failed: {checkin.text}"
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}")
endpoint = signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}")
# Enable metrics to provision dashboards first
enable_response = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(True, logs_enabled=False)},
json={"config": {"aws": {"metrics": {"enabled": True}, "logs": {"enabled": False}}}},
timeout=10,
)
assert enable_response.status_code == HTTPStatus.NO_CONTENT, f"Enable failed: {enable_response.status_code}: {enable_response.text}"
# Capture the provisioned dashboard IDs before disabling
get_svc_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
@@ -579,14 +485,14 @@ def test_disable_metrics_deprovisions_dashboards(
disable_response = requests.put(
endpoint,
headers={"Authorization": f"Bearer {admin_token}"},
json={"config": spec.build_service_config(False)},
json={"config": {"aws": {"metrics": {"enabled": False}, "logs": {"enabled": False}}}},
timeout=10,
)
assert disable_response.status_code == HTTPStatus.NO_CONTENT, f"Disable failed: {disable_response.status_code}: {disable_response.text}"
# Assertion 1: GetService no longer returns UUID dashboard IDs
get_svc_after = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{spec.provider}/accounts/{account_id}/services/{spec.service_id}"),
signoz.self.host_configs["8080"].get(f"/api/v1/cloud_integrations/{CLOUD_PROVIDER}/accounts/{account_id}/services/{SERVICE_ID}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_ai_observability(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""Package-scoped SigNoz with AI observability enabled: the flag gates the static
gen_ai key definitions (enrichWithGenAIKeys) — without it the gate keys only
resolve once a span carrying them has been ingested."""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-ai-observability",
env_overrides={
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_ENABLE__AI__OBSERVABILITY": True,
},
)

View File

@@ -1,14 +1,20 @@
import os
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import get_column_data_from_response, make_query_request
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
FILTER_EXPRESSIONS_FILE = os.path.join(TESTDATA_DIR, "filter_expressions_10000.txt")
@pytest.mark.parametrize(
"expression,expected_logs",
@@ -174,3 +180,101 @@ def test_not_filter_expression(
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
assert set(get_column_data_from_response(response.json(), "body")) == expected_logs
def test_filter_expressions_no_server_error(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
insert_logs,
get_token: Callable[[str, str], str],
) -> None:
"""
Reads every line from filter_expressions_10000.txt and fires it as a filter
expression against the logs query endpoint.
Expressions may be valid (200) or invalid (400) — both are acceptable.
A 500 means the server crashed on the input and is a test failure.
All failing expressions are collected before asserting so the full list is
visible in one run.
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=5),
body="alpha-log",
resources={
"f1": "v10",
"f2": "v20",
"f3": "v30",
},
attributes={
"f4": 40,
"f5": 50,
"f6": 60,
},
),
Logs(
timestamp=now - timedelta(seconds=3),
body="beta-log",
resources={
"f4": "v41",
"f5": "v51",
"f6": "v61",
},
attributes={
"f1": 11,
"f2": 21,
"f3": 31,
},
),
]
)
def _make_raw_logs_query(
signoz: types.SigNoz,
token: str,
filter_expression: str,
) -> requests.Response:
"""Helper to query raw logs with a filter expression over the last 30 seconds."""
now = datetime.now(tz=UTC)
return make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": filter_expression},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
failures: list[str] = []
with ThreadPoolExecutor(max_workers=40) as executor:
with open(FILTER_EXPRESSIONS_FILE, encoding="utf-8") as f:
futures = {executor.submit(_make_raw_logs_query, signoz, token, expr.rstrip("\n")): expr.rstrip("\n") for expr in f}
for future in as_completed(futures):
expr = futures[future]
if future.result().status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
failures.append(expr)
assert len(failures) <= 0, f"{len(failures)} expression(s) caused HTTP 500:\n" + "\n".join(f" {expr!r}" for expr in failures)

View File

@@ -26,14 +26,15 @@ def test_create_rejects_wrong_schema_version(
"name": "my-view",
"generateName": False,
"source": "logs",
"schemaVersion": "v9",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v9",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -58,14 +59,15 @@ def test_create_rejects_invalid_panel_type(
"name": "my-view",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "bogus",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "bogus",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -89,14 +91,15 @@ def test_create_rejects_empty_queries(
"name": "my-view",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -124,14 +127,15 @@ def test_create_rejects_empty_display_name(
"name": "my-view",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -156,14 +160,15 @@ def test_create_rejects_invalid_source(
"name": "my-view",
"generateName": False,
"source": "bogus",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -187,14 +192,15 @@ def test_create_rejects_invalid_name(
"name": "Not A Valid Slug",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -220,14 +226,15 @@ def test_create_rejects_empty_name_without_generate_name(
"name": "",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -254,14 +261,15 @@ def test_create_rejects_name_when_generate_name_is_true(
"name": "explicit-name",
"generateName": True,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -290,14 +298,15 @@ def test_create_rejects_unknown_field(
"name": "my-view",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
"unknownfield": "boom",
},
@@ -357,14 +366,15 @@ def test_update_missing_view_returns_not_found(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
json={
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -392,14 +402,15 @@ def test_update_rejects_name_field(
"name": "update-rejects-name-field",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -413,14 +424,15 @@ def test_update_rejects_name_field(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My View",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
"name": "update-rejects-name-field",
},
@@ -473,14 +485,15 @@ def test_saved_view_lifecycle(
"name": "lc-logs-overview",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -495,14 +508,15 @@ def test_saved_view_lifecycle(
"name": "lc-traces-overview",
"generateName": False,
"source": "traces",
"schemaVersion": "v2",
"spec": {
"displayName": "lc-traces-overview",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-traces-overview",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -521,9 +535,9 @@ def test_saved_view_lifecycle(
got = response.json()["data"]
assert got["id"] == view_id
assert got["name"] == "lc-logs-overview"
assert got["spec"]["displayName"] == "lc-logs-overview"
assert got["data"]["spec"]["displayName"] == "lc-logs-overview"
assert got["source"] == "logs"
assert got["spec"]["panelType"] == "table"
assert got["data"]["spec"]["panelType"] == "table"
# ── list filters by source and name ──────────────────────────────
response = requests.get(
@@ -550,14 +564,15 @@ def test_saved_view_lifecycle(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "metrics",
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview-renamed",
"requestType": "time_series",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "graph",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "lc-logs-overview-renamed",
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -573,9 +588,9 @@ def test_saved_view_lifecycle(
assert response.status_code == HTTPStatus.OK, response.text
updated = response.json()["data"]
assert updated["name"] == "lc-logs-overview", "name is immutable"
assert updated["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["data"]["spec"]["displayName"] == "lc-logs-overview-renamed"
assert updated["source"] == "metrics"
assert updated["spec"]["panelType"] == "graph"
assert updated["data"]["spec"]["panelType"] == "graph"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -606,14 +621,15 @@ def test_empty_name_derives_a_slug_from_display_name(
"name": "",
"generateName": True,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "My Generated View!",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "My Generated View!",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -630,7 +646,7 @@ def test_empty_name_derives_a_slug_from_display_name(
)
assert response.status_code == HTTPStatus.OK, response.text
got = response.json()["data"]
assert got["spec"]["displayName"] == "My Generated View!"
assert got["data"]["spec"]["displayName"] == "My Generated View!"
assert got["name"].startswith("my-generated-view-")
assert got["name"] != "my-generated-view-", "expected a random suffix, not just the slugified prefix"
finally:
@@ -665,14 +681,15 @@ def test_create_roundtrip_preserves_zero_values(
"name": "create-zero-values",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "create-zero-values",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "create-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -688,11 +705,10 @@ def test_create_roundtrip_preserves_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
spec = response.json()["data"]["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
("panelType preserved", spec["panelType"], "table"),
("maxLines 0", spec["display"]["maxLines"], 0),
("fontSize empty", spec["display"]["fontSize"], ""),
("format empty", spec["display"]["format"], ""),
@@ -711,30 +727,28 @@ def test_create_roundtrip_preserves_zero_values(
)
def test_selected_fields_and_display_omitted_on_create_read_back_as_empty_defaults(
def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Neither selectedFields nor display is required. Omitting both entirely
must not 400 or leave either null on read-back: selectedFields defaults to
an empty list, display to its zero-value object. panelType is a separate,
required, top-level field and is supplied here so the create succeeds."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "omitted-selected-fields-and-display",
"name": "omitted-selected-fields",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "omitted-selected-fields-and-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"panelType": "table",
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "omitted-selected-fields",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -750,108 +764,7 @@ def test_selected_fields_and_display_omitted_on_create_read_back_as_empty_defaul
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
assert spec["selectedFields"] == []
assert spec["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": ""}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_selected_fields_and_display_explicit_null_on_create(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""JSON null decodes as a no-op onto a non-pointer Go field (struct/slice), so
an explicit null is expected to behave identically to omitting the field."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "null-selected-fields-and-display",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "null-selected-fields-and-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"panelType": "table",
"selectedFields": None,
"display": None,
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
assert spec["selectedFields"] == []
assert spec["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": ""}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
def test_create_with_partial_display_defaults_missing_fields(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""display's fields are each independently optional -- sending only one
(color) must not 400, and the fields left unset must default to their own
zero value rather than being rejected or dropped."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "partial-display-color-only",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "partial-display-color-only",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"panelType": "table",
"selectedFields": [],
"display": {"color": "test"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": "test"}
assert response.json()["data"]["data"]["spec"]["selectedFields"] == []
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
@@ -880,14 +793,15 @@ def test_update_does_not_corrupt_zero_values(
"name": "update-zero-values",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": True, "legend": "Custom Legend"}}],
"selectedFields": [{"name": "service.name"}],
"panelType": "table",
"display": {"maxLines": 25, "fontSize": "large", "format": "table", "color": "blue"},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": True, "legend": "Custom Legend"}}],
"selectedFields": [{"name": "service.name"}],
"display": {"maxLines": 25, "fontSize": "large", "format": "table", "color": "blue"},
},
},
},
headers=headers,
@@ -903,7 +817,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
spec = response.json()["data"]["data"]["spec"]
assert spec["display"]["maxLines"] == 25
# signal/fieldContext/fieldDataType always serialize on TelemetryFieldKey
# (no omitempty -- see pkg/types/telemetrytypes/field.go), so an entry sent
@@ -916,14 +830,15 @@ def test_update_does_not_corrupt_zero_values(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "update-zero-values",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}], "disabled": False, "legend": ""}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers=headers,
@@ -938,7 +853,7 @@ def test_update_does_not_corrupt_zero_values(
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
spec = response.json()["data"]["spec"]
spec = response.json()["data"]["data"]["spec"]
query = spec["queries"][0]["spec"]
cases = [
@@ -958,71 +873,3 @@ def test_update_does_not_corrupt_zero_values(
headers=headers,
timeout=5,
)
def test_update_with_partial_display_replaces_whole_object(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""Update is a whole-object replace, not a merge: sending only "color" on
update must not preserve the previous fontSize/format/maxLines -- those
reset to their zero value exactly as if display had been sent in full."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json={
"name": "update-partial-display",
"generateName": False,
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-partial-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 10, "fontSize": "large", "format": "table", "color": "blue"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
view_id = response.json()["data"]["id"]
try:
response = requests.put(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
json={
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "update-partial-display",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"color": "green"},
},
},
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["spec"]["display"] == {"maxLines": 0, "fontSize": "", "format": "", "color": "green"}
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
headers=headers,
timeout=5,
)

View File

@@ -112,14 +112,15 @@ def test_write_forbidden_without_grant(
signoz.self.host_configs["8080"].get(f"{SAVED_VIEW_BASE}/{target_id}"),
json={
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -132,14 +133,15 @@ def test_write_forbidden_without_grant(
json={
"name": "saved-view-fga-create-attempt",
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": "saved-view-fga-create-attempt",
"requestType": "scalar",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "table",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": "saved-view-fga-create-attempt",
"panelType": "table",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
},
headers={"Authorization": f"Bearer {token}"},
@@ -210,14 +212,15 @@ def test_update_scoped_to_granted_view(
token = get_token(_SAVED_VIEW_FGA_CUSTOM_USER_EMAIL, _SAVED_VIEW_FGA_CUSTOM_USER_PASSWORD)
updated_body = {
"source": "logs",
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"requestType": "time_series",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"panelType": "graph",
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
"data": {
"schemaVersion": "v2",
"spec": {
"displayName": _SAVED_VIEW_FGA_TARGET_NAME,
"panelType": "graph",
"queries": [{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}],
"selectedFields": [],
"display": {"maxLines": 0, "fontSize": "", "format": "", "color": ""},
},
},
}