Compare commits

..

32 Commits

Author SHA1 Message Date
nityanandagohain
d81ae64515 fix: refactor integration tests 2026-07-30 14:52:04 +05:30
nityanandagohain
13b25a6be1 Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-30 14:11:04 +05:30
nityanandagohain
d6d09549f0 fix: add NewFactory 2026-07-29 19:14:35 +05:30
nityanandagohain
d71c0c938a fix: remove accidentally added file 2026-07-29 17:01:11 +05:30
nityanandagohain
e911223b1d Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-29 16:45:11 +05:30
nityanandagohain
cb3cc3de56 fix: remove comment 2026-07-29 16:04:49 +05:30
nityanandagohain
017e62814a Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-29 15:45:35 +05:30
Nityananda Gohain
f10f526249 Merge branch 'main' into issue_5601 2026-07-29 14:14:37 +05:30
nityanandagohain
a5350682c3 fix: remove tracefield. explicit rejection 2026-07-28 16:54:26 +05:30
nityanandagohain
a7b74005ea fix: address comments 2026-07-28 16:43:20 +05:30
Nityananda Gohain
787e1be974 Merge branch 'main' into issue_5601 2026-07-28 15:55:47 +05:30
nityanandagohain
19ee51be66 fix: address comments 2026-07-28 15:50:28 +05:30
nityanandagohain
b2c5af428e fix: refactor as requested 2026-07-24 12:07:37 +05:30
nityanandagohain
093f9b41c4 fix: minor cleanup 2026-07-22 16:22:19 +05:30
nityanandagohain
596e128005 fix: updated openapi 2026-07-22 15:26:52 +05:30
nityanandagohain
2e1da92367 fix: remove source and change to builder ai query 2026-07-22 15:24:43 +05:30
nityanandagohain
1e8d72e8fa Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-21 17:08:34 +05:30
nityanandagohain
cce080e1ae fix: add back the flag in metadata 2026-07-16 11:37:57 +05:30
nityanandagohain
deca060a4d Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-16 11:00:55 +05:30
nityanandagohain
03c7e524e7 fix: fix tests 2026-07-15 20:23:33 +05:30
nityanandagohain
815dc7d88b Merge remote-tracking branch 'origin/main' into issue_5601 2026-07-15 20:10:05 +05:30
nityanandagohain
f50d9199fe fix: address comments 2026-07-15 19:47:39 +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
84 changed files with 3907 additions and 4955 deletions

View File

@@ -53,6 +53,7 @@ jobs:
- queriermetrics
- querierscalar
- queriercommon
- querierai
- rawexportdata
- promqlconformance
- querierauthz

View File

@@ -1497,8 +1497,6 @@ components:
- cloudsql_postgres
- memorystore_redis
- computeengine
- gke
- cloudstorage
type: string
CloudintegrationtypesServiceMetadata:
properties:
@@ -6902,6 +6900,7 @@ components:
Querybuildertypesv5QueryEnvelope:
discriminator:
mapping:
builder_ai_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
builder_formula: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
builder_query: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
builder_trace_operator: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
@@ -6910,6 +6909,7 @@ components:
propertyName: type
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator'
- $ref: '#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL'
@@ -6924,6 +6924,15 @@ components:
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeBuilderAI:
properties:
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
type:
$ref: '#/components/schemas/Querybuildertypesv5QueryType'
required:
- type
type: object
Querybuildertypesv5QueryEnvelopeClickHouseSQL:
properties:
spec:
@@ -7037,6 +7046,7 @@ components:
Querybuildertypesv5QueryType:
enum:
- builder_query
- builder_ai_query
- builder_formula
- builder_trace_operator
- clickhouse_sql

View File

@@ -2816,8 +2816,6 @@ export enum CloudintegrationtypesServiceIDDTO {
cloudsql_postgres = 'cloudsql_postgres',
memorystore_redis = 'memorystore_redis',
computeengine = 'computeengine',
gke = 'gke',
cloudstorage = 'cloudstorage',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**
@@ -4301,6 +4299,18 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderDTO {
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType;
}
export enum Querybuildertypesv5QueryEnvelopeBuilderAIDTOType {
builder_ai_query = 'builder_ai_query',
}
export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
spec?: Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregationDTO;
/**
* @type string
* @enum builder_ai_query
*/
type: Querybuildertypesv5QueryEnvelopeBuilderAIDTOType;
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
/**
* @type boolean
@@ -4484,6 +4494,7 @@ export interface Querybuildertypesv5QueryEnvelopeClickHouseSQLDTO {
export type Querybuildertypesv5QueryEnvelopeDTO =
| Querybuildertypesv5QueryEnvelopeBuilderDTO
| Querybuildertypesv5QueryEnvelopeBuilderAIDTO
| Querybuildertypesv5QueryEnvelopeFormulaDTO
| Querybuildertypesv5QueryEnvelopeTraceOperatorDTO
| Querybuildertypesv5QueryEnvelopePromQLDTO
@@ -8287,6 +8298,7 @@ export interface Querybuildertypesv5QueryRangeResponseDTO {
export enum Querybuildertypesv5QueryTypeDTO {
builder_query = 'builder_query',
builder_ai_query = 'builder_ai_query',
builder_formula = 'builder_formula',
builder_trace_operator = 'builder_trace_operator',
clickhouse_sql = 'clickhouse_sql',

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}.cls-4{fill:#fff;}</style></defs><title>Icon_24px_CloudStorage_Color</title><g data-name="Product Icons"><rect class="cls-1" x="2" y="4" width="20" height="7"/><rect class="cls-2" x="20" y="4" width="2" height="7"/><polygon class="cls-3" points="22 4 20 4 20 11 22 4"/><rect class="cls-2" x="2" y="4" width="2" height="7"/><rect class="cls-4" x="6" y="7" width="6" height="1"/><rect class="cls-4" x="15" y="6" width="3" height="3" rx="1.5"/><rect class="cls-1" x="2" y="13" width="20" height="7"/><rect class="cls-2" x="20" y="13" width="2" height="7"/><polygon class="cls-3" points="22 13 20 13 20 20 22 13"/><rect class="cls-2" x="2" y="13" width="2" height="7"/><rect class="cls-4" x="6" y="16" width="6" height="1"/><rect class="cls-4" x="15" y="15" width="3" height="3" rx="1.5"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1200" height="800" viewBox="-19.2 -28.483 166.401 170.898"><g transform="translate(0 -7.034)"><linearGradient id="a" x1="64" x2="64" y1="7.034" y2="120.789" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#4387fd"/><stop offset="1" stop-color="#4683ea"/></linearGradient><path fill="url(#a)" d="M27.79 115.217 1.54 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.96-5.75h52.5a11.5 11.5 0 0 1 9.959 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.959 5.75h-52.5a11.5 11.5 0 0 1-9.96-5.75z"/></g><g transform="translate(0 -7.034)"><defs><path id="b" d="M27.791 115.217 1.541 69.749a11.5 11.5 0 0 1 0-11.499l26.25-45.467a11.5 11.5 0 0 1 9.959-5.75h52.5a11.5 11.5 0 0 1 9.96 5.75l26.25 45.467a11.5 11.5 0 0 1 0 11.5l-26.25 45.466a11.5 11.5 0 0 1-9.96 5.75h-52.5a11.5 11.5 0 0 1-9.959-5.75z"/></defs><clipPath id="c"><use xlink:href="#b" width="100%" height="100%" overflow="visible"/></clipPath><path d="m49.313 53.875-7.01 6.99 5.957 5.958-5.898 10.476 44.635 44.636 10.816.002L118.936 84 85.489 50.55z" clip-path="url(#c)" opacity=".07"/></g><path fill="#fff" d="M84.7 43.236H43.264c-.667 0-1.212.546-1.212 1.214v8.566c0 .666.546 1.212 1.212 1.212H84.7c.667 0 1.213-.546 1.213-1.212v-8.568c0-.666-.545-1.213-1.212-1.213m-6.416 7.976a2.484 2.484 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477c1.37 0 2.48 1.103 2.48 2.477a2.48 2.48 0 0 1-2.48 2.48m6.415 8.491-41.436.002c-.667 0-1.212.546-1.212 1.214v8.565c0 .666.546 1.213 1.212 1.213H84.7c.667 0 1.213-.547 1.213-1.213v-8.567c0-.666-.545-1.214-1.212-1.214m-6.416 7.976a2.483 2.483 0 0 1-2.477-2.48 2.475 2.475 0 0 1 2.477-2.477 2.48 2.48 0 1 1 0 4.956"/></svg>

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#4285f4;}.cls-1,.cls-2,.cls-4{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3,.cls-4{fill:#aecbfa;}</style></defs><title>Icon_24px_K8Engine_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="14.68 13.06 19.23 15.69 19.23 16.68 14.29 13.83 14.68 13.06"/><polygon class="cls-2" points="9.98 13.65 4.77 16.66 4.45 15.86 9.53 12.92 9.98 13.65"/><rect class="cls-3" x="11.55" y="3.29" width="0.86" height="5.78"/><path class="cls-4" d="M3.25,7V17L12,22l8.74-5V7L12,2Zm15.63,8.89L12,19.78,5.12,15.89V8.11L12,4.22l6.87,3.89v7.78Z"/><polygon class="cls-4" points="11.98 11.5 15.96 9.21 11.98 6.91 8.01 9.21 11.98 11.5"/><polygon class="cls-2" points="11.52 12.3 7.66 10.01 7.66 14.6 11.52 16.89 11.52 12.3"/><polygon class="cls-1" points="12.48 12.3 12.48 16.89 16.34 14.6 16.34 10.01 12.48 12.3"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M19.73 6.56a1.73 1.73 0 0 0-1.68 1.68 1.83 1.83 0 0 0 .89 1.48v6.9l-5.16 3.06.8 1.28 5.55-3.25a.84.84 0 0 0 .4-.69v-7.3a1.64 1.64 0 0 0 .89-1.48 1.61 1.61 0 0 0-1.69-1.68" style="fill:#aecbfa"/><path d="m18 5.48-5.61-3.16a1.18 1.18 0 0 0-.79 0L5.25 6a1.72 1.72 0 0 0-2.68 1.35A1.73 1.73 0 0 0 4.26 9a1.73 1.73 0 0 0 1.68-1.65L12 3.9l5.15 3ZM11.2 18.5a1.57 1.57 0 0 0-.89.29l-5.16-3V9.92H3.56v6.31a.84.84 0 0 0 .4.69L9.52 20v.09a1.69 1.69 0 0 0 3.37 0 1.65 1.65 0 0 0-1.69-1.59" data-name="Path" style="fill:#aecbfa"/><path d="M16.96 8.63 12.1 5.78 7.13 8.63l4.97 2.77z" data-name="Path" style="fill:#669df6"/><path d="M12.1 12.38 6.84 9.32v2.47l5.26 2.96zM12.1 15.73l-5.26-3.05v2.07l5.26 3.06z" style="fill:#669df6"/><path d="M12.09 12.38v2.37l5.26-3.06V9.33Zm4.32-.94a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76M12.09 15.73v2.07l5.26-3.05v-2.07Zm4.32-1.07a.38.38 0 1 1 0-.76.38.38 0 0 1 0 .76" style="fill:#4285f4"/></svg>

Before

Width:  |  Height:  |  Size: 941 B

After

Width:  |  Height:  |  Size: 988 B

View File

@@ -45,5 +45,4 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
}

View File

@@ -15,18 +15,6 @@ jest.mock('@signozhq/ui/sonner', () => ({
},
}));
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
// Admin gating on the write controls flows through useAuthZ. Mock it directly
// (synchronous) so the functional tests stay synchronous; the read-only block
// below flips it to deny-all.
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
import {
GROUPS_ENDPOINT,
makeGroupsResponse,
@@ -114,8 +102,6 @@ describe('AttributeMappingsTab (integration)', () => {
beforeEach(() => {
// Reset URL state between tests — jsdom shares window.location across a file.
window.history.pushState(null, '', '/');
// Default to an admin; the read-only block overrides this.
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
@@ -521,55 +507,4 @@ describe('AttributeMappingsTab (integration)', () => {
).resolves.toBeInTheDocument();
});
});
// The write APIs (create/update/delete group & mapper) are Admin-only on the
// backend, so a non-admin gets a read-only view: the data renders, but every
// write control is hidden.
describe('read-only (non-admin)', () => {
beforeEach(() => {
mockedUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
});
it('hides the "Add a new group" button', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
expect(screen.queryByTestId('add-group-row')).not.toBeInTheDocument();
});
it('hides the group enable toggle and actions menu', async () => {
setupGroups();
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
expect(
screen.queryByTestId('group-enabled-group-1'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('group-actions-group-1'),
).not.toBeInTheDocument();
});
it('renders mappers read-only: no "Add mapping" button or row actions', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
setupGroups();
setupMappers([makeMapper({ id: 'mapper-1', name: 'gen_ai.request.model' })]);
render(<AttributeMappingsTabWithStore />);
await screen.findByTestId('group-name-group-1');
await expandGroup(user);
// The mapper still renders...
await screen.findByTestId('mapper-target-mapper-1');
// ...but every write control is gone.
expect(screen.queryByTestId('add-mapper-group-1')).not.toBeInTheDocument();
expect(
screen.queryByTestId('mapper-enabled-mapper-1'),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Mapping actions' }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,7 +1,6 @@
import { Switch } from '@signozhq/ui/switch';
import { DraftGroup } from 'container/LLMObservability/AttributeMapping/types';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import GroupActionsMenu from '../GroupActionsMenu/GroupActionsMenu';
import styles from './GroupHeaderActions.module.scss';
@@ -17,11 +16,7 @@ function GroupHeaderActions({
onToggle,
onEdit,
onRemove,
}: GroupHeaderActionsProps): JSX.Element | null {
const canManage = useCanManageAttributeMapping();
if (!canManage) {
return null;
}
}: GroupHeaderActionsProps): JSX.Element {
return (
<div
className={styles.actions}

View File

@@ -11,7 +11,6 @@ import {
Mapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import { COLUMN_COUNT } from '../constants';
import MapperRow from '../MapperRow/MapperRow';
import MapperRowSkeleton from '../MapperRow/MapperRowSkeleton';
@@ -40,7 +39,6 @@ function GroupMappers({
onEditMapper,
}: GroupMappersProps): JSX.Element {
const { hydrateGroupMappers, removeMapper, toggleMapper } = editor;
const canManage = useCanManageAttributeMapping();
const hasServerId = group.serverId !== null;
const { data, isLoading, isError } = useListSpanMappers(
@@ -146,20 +144,16 @@ function GroupMappers({
/>
));
// The add-mapping row trails every non-error state (including loading/empty),
// but only for users who can manage mappings — non-admins get a read-only view.
// The add-mapping row trails every non-error state (including loading/empty).
let rows: JSX.Element[];
if (isErrorMappers) {
rows = [errorRow];
} else if (isLoadingMappers && mapperCount === 0) {
rows = [...skeletonRows];
rows = [...skeletonRows, addMapperRow];
} else if (mapperCount === 0) {
rows = [emptyRow];
rows = [emptyRow, addMapperRow];
} else {
rows = [...mapperRows];
}
if (canManage && !isErrorMappers) {
rows.push(addMapperRow);
rows = [...mapperRows, addMapperRow];
}
return (

View File

@@ -6,7 +6,6 @@ import cx from 'classnames';
import { motion } from 'motion/react';
import { DraftMapper } from 'container/LLMObservability/AttributeMapping/types';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import MapperActionsMenu from '../MapperActionsMenu/MapperActionsMenu';
import styles from './MapperRow.module.scss';
@@ -31,7 +30,6 @@ function MapperRow({
onRemove,
onToggle,
}: MapperRowProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
const sources = mapper.sources ?? [];
const visibleSources = sources.slice(0, MAX_VISIBLE_SOURCES);
const remainingSources = sources.length - visibleSources.length;
@@ -103,16 +101,14 @@ function MapperRow({
)}
</td>
<td className={cx(styles.cell, styles.actionsCell)}>
{canManage && (
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
)}
<div className={styles.rowActions}>
<Switch
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}
/>
<MapperActionsMenu mapper={mapper} onEdit={onEdit} onRemove={onRemove} />
</div>
</td>
</motion.tr>
);

View File

@@ -11,7 +11,6 @@ import {
DraftMapper,
} from 'container/LLMObservability/AttributeMapping/types';
import { AttributeMappingEditor } from 'container/LLMObservability/AttributeMapping/hooks/useAttributeMappingEditor';
import { useCanManageAttributeMapping } from 'container/LLMObservability/AttributeMapping/hooks/useCanManageAttributeMapping';
import GroupHeader from './GroupHeader/GroupHeader';
import GroupHeaderActions from './GroupHeaderActions/GroupHeaderActions';
import GroupMappers from './GroupMappers/GroupMappers';
@@ -34,7 +33,6 @@ function MappingsTable({
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
const [targetGroupId, setTargetGroupId] = useState<string | null>(null);
const drawer = useMapperFormDrawer();
const canManage = useCanManageAttributeMapping();
const { upsertMapper, removeMapper } = editor;
@@ -122,20 +120,18 @@ function MappingsTable({
return (
<div className={styles.tableWrapper}>
{canManage && (
<div className={styles.toolbar}>
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={onAddGroup}
testId="add-group-row"
disabled={editor.isLoading}
>
Add a new group
</Button>
</div>
)}
<div className={styles.toolbar}>
<Button
variant="link"
color="primary"
prefix={<Plus size={14} />}
onClick={onAddGroup}
testId="add-group-row"
disabled={editor.isLoading}
>
Add a new group
</Button>
</div>
{isEmpty ? (
<div className={styles.tableState} data-testid="mapper-groups-empty">

View File

@@ -8,18 +8,12 @@ import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
import styles from './LLMObservabilityAttributeMapping.module.scss';
import TestTab from './TestTab/TestTab';
import { useAttributeMappingEditor } from './hooks/useAttributeMappingEditor';
import { useGroupFormDrawer } from './components/GroupFormDrawer/hooks/useGroupFormDrawer';
import { useTestSpanMapper } from './TestTab/useTestSpanMapper';
const MAPPINGS_TAB_KEY = 'attribute-mappings';
const TEST_TAB_KEY = 'test';
function LLMObservabilityAttributeMapping(): JSX.Element {
const editor = useAttributeMappingEditor();
const groupDrawer = useGroupFormDrawer();
const spanTest = useTestSpanMapper(editor.snapshot, editor.groups);
const { discard } = editor;
// Discarding wipes the whole working copy, so gate it behind a confirm
@@ -37,7 +31,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
const tabItems = [
{
key: MAPPINGS_TAB_KEY,
key: 'attribute-mappings',
label: 'Attribute Mappings',
children: (
<AttributeMappingsTab
@@ -48,9 +42,11 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
),
},
{
key: TEST_TAB_KEY,
key: 'test',
label: 'Test',
children: <TestTab spanTest={spanTest} />,
disabled: true,
disabledReason: 'Coming soon',
children: null,
},
];
@@ -75,7 +71,7 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
<Tabs
testId="attribute-mapping-tabs"
defaultValue={MAPPINGS_TAB_KEY}
defaultValue="attribute-mappings"
items={tabItems}
/>
{groupDrawer.isOpen && (

View File

@@ -1,54 +0,0 @@
.skeleton {
width: 100%;
height: 100%;
overflow: hidden;
background: var(--l1-background);
}
.line {
display: flex;
align-items: center;
height: 18px;
}
.lineNumber {
flex: 0 0 40px;
padding-right: var(--padding-3);
text-align: right;
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
opacity: 0.6;
user-select: none;
}
.lineContent {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
}
.bar {
height: 8px;
border-radius: var(--radius-1);
background: var(--l2-border);
animation: jsonSkeletonPulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.bar {
animation: none;
}
}
@keyframes jsonSkeletonPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}

View File

@@ -1,56 +0,0 @@
import styles from './JsonEditorSkeleton.module.scss';
interface SkeletonLine {
indent: number;
barWidths: number[];
}
const SKELETON_LINES: SkeletonLine[] = [
{ indent: 0, barWidths: [10] },
{ indent: 1, barWidths: [90, 10] },
{ indent: 2, barWidths: [155, 190] },
{ indent: 2, barWidths: [135, 190] },
{ indent: 2, barWidths: [150, 50] },
{ indent: 2, barWidths: [180, 35] },
{ indent: 2, barWidths: [185, 200] },
{ indent: 1, barWidths: [14] },
{ indent: 1, barWidths: [75, 10] },
{ indent: 2, barWidths: [95, 90] },
{ indent: 2, barWidths: [170, 85] },
{ indent: 1, barWidths: [10] },
{ indent: 0, barWidths: [10] },
];
const INDENT_WIDTH_PX = 14;
function JsonEditorSkeleton(): JSX.Element {
return (
<div
className={styles.skeleton}
data-testid="json-editor-skeleton"
aria-hidden="true"
>
{SKELETON_LINES.map((line, lineIndex) => (
// eslint-disable-next-line react/no-array-index-key
<div key={lineIndex} className={styles.line}>
<span className={styles.lineNumber}>{lineIndex + 1}</span>
<span
className={styles.lineContent}
style={{ paddingLeft: line.indent * INDENT_WIDTH_PX }}
>
{line.barWidths.map((width, barIndex) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={barIndex}
className={styles.bar}
style={{ width }}
/>
))}
</span>
</div>
))}
</div>
);
}
export default JsonEditorSkeleton;

View File

@@ -1,121 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import { SpantypesSpanMapperTestSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { useMemo } from 'react';
import {
AttrChangeStatus,
AttrDiffEntry,
diffAttributeMaps,
formatAttributeValue,
} from './testPayload';
import styles from './TestTab.module.scss';
import { TestTabAttributes, TestTabResource } from './useTestSpanMapper';
interface TestResultProps {
index: number;
span: SpantypesSpanMapperTestSpanDTO;
inputAttributes: TestTabAttributes;
inputResource: TestTabResource;
}
const STATUS_BADGE: Partial<
Record<
AttrChangeStatus,
{ color: 'success' | 'robin' | 'sienna'; label: string }
>
> = {
added: { color: 'success', label: 'populated' },
changed: { color: 'robin', label: 'remapped' },
removed: { color: 'sienna', label: 'moved out' },
};
const ROW_CLASS: Partial<Record<AttrChangeStatus, string>> = {
added: styles.added,
removed: styles.removed,
};
interface ResultSection {
key: string;
title: string;
entries: AttrDiffEntry[];
}
function TestResult({
index,
span,
inputAttributes,
inputResource,
}: TestResultProps): JSX.Element {
const attributeEntries = useMemo(
() => diffAttributeMaps(inputAttributes, span.attributes ?? {}),
[inputAttributes, span.attributes],
);
const resourceEntries = useMemo(
() => diffAttributeMaps(inputResource, span.resource ?? {}),
[inputResource, span.resource],
);
const sections: ResultSection[] = [
{
key: 'attributes',
title: 'Resulting attributes',
entries: attributeEntries,
},
];
if (resourceEntries.length > 0) {
sections.push({
key: 'resource',
title: 'Resulting resource',
entries: resourceEntries,
});
}
return (
<div className={styles.resultCard} data-testid={`test-result-${index}`}>
{sections.map((section) => (
<div
key={section.key}
className={styles.resultSection}
data-testid={`test-result-${index}-${section.key}`}
>
<div className={styles.resultTitle}>{section.title}</div>
{section.entries.length === 0 ? (
<div className={styles.resultEmpty}>No keys in this map.</div>
) : (
<div className={styles.attrRows}>
{section.entries.map((entry) => {
const badge = STATUS_BADGE[entry.status];
return (
<div
key={entry.key}
className={`${styles.attrRow} ${ROW_CLASS[entry.status] ?? ''}`}
>
<span className={styles.attrKey} title={entry.key}>
{entry.key}
</span>
<span
className={styles.attrValue}
title={formatAttributeValue(entry.value)}
>
{formatAttributeValue(entry.value)}
</span>
{badge ? (
<Badge color={badge.color} variant="outline">
{badge.label}
</Badge>
) : (
<span />
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
);
}
export default TestResult;

View File

@@ -1,177 +0,0 @@
.testTab {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
margin-top: var(--spacing-6);
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-6);
}
.headerActions {
display: flex;
align-items: center;
gap: var(--spacing-3);
flex-shrink: 0;
}
.headerText {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
gap: var(--spacing-3);
margin: 0;
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.description {
margin: 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-6);
height: calc(100vh - 320px);
min-height: 320px;
}
.editor {
width: 100%;
height: 100%;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}
.validationCallout {
width: 100%;
}
// No top padding so results align with the editor's first line, which sits
// flush against the top of its own panel.
.resultsPanel {
height: 100%;
overflow-y: auto;
padding: var(--padding-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
}
// Pre-run empty state that fills the blank right half.
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-2);
height: 100%;
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.placeholderTitle {
font-weight: var(--font-weight-semibold);
color: var(--l2-foreground);
}
.error {
padding: var(--padding-3) var(--padding-4);
border-radius: var(--radius-2);
background: var(--callout-error-background);
color: var(--callout-error-title);
font-size: var(--periscope-font-size-base);
}
.results {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.resultCard {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
border-radius: var(--radius-2);
background: var(--l1-background);
}
.resultSection {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.resultTitle {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-semibold);
}
.resultEmpty {
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}
.attrRows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.attrRow {
display: grid;
grid-template-columns: clamp(200px, 40%, 340px) minmax(0, 1fr) auto;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--padding-3);
border-radius: var(--radius-2);
font-family: 'Geist Mono', monospace;
font-size: var(--font-size-xs);
&.added {
background: var(--callout-success-background);
}
&.removed {
opacity: 0.65;
.attrKey,
.attrValue {
text-decoration: line-through;
}
}
}
.attrKey,
.attrValue {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attrKey {
font-weight: var(--font-weight-medium);
}
.attrValue {
color: var(--l3-foreground);
}

View File

@@ -1,162 +0,0 @@
import MEditor from '@monaco-editor/react';
import { Play, RotateCcw } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { useIsDarkMode } from 'hooks/useDarkMode';
import styles from './TestTab.module.scss';
import JsonEditorSkeleton from './JsonEditorSkeleton';
import TestResult from './TestResult';
import {
defineSignozJsonTheme,
SIGNOZ_JSON_THEME_DARK,
SIGNOZ_JSON_THEME_LIGHT,
} from './jsonEditorTheme';
import { UseTestSpanMapper } from './useTestSpanMapper';
interface TestTabProps {
spanTest: UseTestSpanMapper;
}
function TestTab({ spanTest }: TestTabProps): JSX.Element {
const {
input,
setInput,
run,
resetToTemplate,
isTemplateInput,
isRunning,
result,
testedAttributes,
testedResource,
error,
validationError,
} = spanTest;
const isDarkMode = useIsDarkMode();
function renderResults(): JSX.Element {
if (error) {
return (
<div className={styles.error} role="alert" data-testid="test-error">
{error}
</div>
);
}
if (result) {
if (result.length === 0) {
return (
<div className={styles.resultEmpty} data-testid="test-results-empty">
No spans returned. The mappers produced no output for this input.
</div>
);
}
return (
<div className={styles.results} data-testid="test-results">
{result.map((span, index) => (
<TestResult
// eslint-disable-next-line react/no-array-index-key
key={index}
index={index}
span={span}
inputAttributes={testedAttributes ?? {}}
inputResource={testedResource ?? {}}
/>
))}
</div>
);
}
return (
<div className={styles.placeholder} data-testid="test-results-placeholder">
<span className={styles.placeholderTitle}>No results yet</span>
<span>
Run the test to see which target attributes your mappers populate.
</span>
</div>
);
}
return (
<div className={styles.testTab} data-testid="test-tab">
<div className={styles.header}>
<div className={styles.headerText}>
<h3 className={styles.heading}>Test with sample span</h3>
<p className={styles.description}>
Paste a JSON span object to see which target attributes get populated and
which source key matched.
</p>
</div>
<div className={styles.headerActions}>
<Button
testId="reset-template-button"
variant="outlined"
color="secondary"
prefix={<RotateCcw size={14} />}
onClick={resetToTemplate}
disabled={isTemplateInput}
>
Reset to Default Span
</Button>
<Button
testId="run-test-button"
variant="solid"
color="primary"
prefix={<Play size={14} />}
onClick={run}
loading={isRunning}
disabled={isRunning || validationError !== null}
>
Run Test
</Button>
</div>
</div>
<div className={styles.body}>
<div
className={styles.editor}
data-testid="test-span-input"
aria-label="Sample span JSON"
>
<MEditor
language="json"
value={input}
onChange={(value): void => setInput(value ?? '')}
height="100%"
loading={<JsonEditorSkeleton />}
theme={isDarkMode ? SIGNOZ_JSON_THEME_DARK : SIGNOZ_JSON_THEME_LIGHT}
beforeMount={defineSignozJsonTheme}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: 'on',
wordWrap: 'on',
scrollBeyondLastLine: false,
formatOnPaste: true,
tabSize: 2,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
</div>
<div className={styles.resultsPanel} data-testid="test-results-panel">
{renderResults()}
</div>
</div>
{validationError && (
<Callout
type="error"
size="small"
showIcon
title={validationError}
testId="test-input-error"
className={styles.validationCallout}
/>
)}
</div>
);
}
export default TestTab;

View File

@@ -1,38 +0,0 @@
//TODO: This is a local theme for now, but ideally for the editor as well. Just like prettyViewer, we have a theme. We should have a theme for the editor as well.
import { Monaco } from '@monaco-editor/react';
import { Color } from '@signozhq/design-tokens';
export const SIGNOZ_JSON_THEME_DARK = 'signoz-attr-mapping-json-dark';
export const SIGNOZ_JSON_THEME_LIGHT = 'signoz-attr-mapping-json-light';
const SHARED_THEME = {
inherit: true,
colors: {
'editor.background': '#00000000', // transparent — inherit the panel bg
},
fontFamily: 'SF Mono, Geist Mono, Fira Code, monospace',
fontSize: 12,
fontWeight: 'normal',
lineHeight: 18,
letterSpacing: -0.06,
};
export function defineSignozJsonTheme(monaco: Monaco): void {
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_DARK, {
...SHARED_THEME,
base: 'vs-dark',
rules: [
{ token: 'string.key.json', foreground: Color.BG_ROBIN_400 },
{ token: 'string.value.json', foreground: Color.BG_VANILLA_400 },
],
});
monaco.editor.defineTheme(SIGNOZ_JSON_THEME_LIGHT, {
...SHARED_THEME,
base: 'vs',
rules: [
{ token: 'string.key.json', foreground: Color.BG_ROBIN_500 },
{ token: 'string.value.json', foreground: Color.BG_INK_400 },
],
});
}

View File

@@ -1,53 +0,0 @@
import get from 'api/browser/localstorage/get';
import remove from 'api/browser/localstorage/remove';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { parseSpanInput } from './testPayload';
export const SAMPLE_SPAN_JSON = `{
"attributes": {
"my_company.llm.input": "What is quantum computing?",
"llm.input_messages": "What is quantum computing?",
"gen_ai.request.model": "gpt-4",
"gen_ai.usage.total_tokens": 1250,
"gen_ai.content.completion": "Quantum computing leverages..."
},
"resource": {
"service.name": "llm-gateway",
"deployment.environment": "production"
}
}`;
function hasNoSpanData(input: string): boolean {
let span;
try {
span = parseSpanInput(input);
} catch {
return false;
}
return (
Object.keys(span.attributes ?? {}).length === 0 &&
Object.keys(span.resource ?? {}).length === 0
);
}
export function getStoredSpanInput(): string {
const stored = get(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
if (!stored?.trim() || hasNoSpanData(stored)) {
return SAMPLE_SPAN_JSON;
}
return stored;
}
export function setStoredSpanInput(value: string): void {
if (!value.trim() || hasNoSpanData(value)) {
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
return;
}
set(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN, value);
}
export function clearStoredSpanInput(): void {
remove(LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN);
}

View File

@@ -1,145 +0,0 @@
import {
SpantypesPostableSpanMapperTestDTO,
SpantypesPostableSpanMapperTestGroupDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { isEqual } from 'lodash-es';
import { DraftGroup } from '../types';
import {
buildPostableGroup,
buildPostableMapper,
groupDraftFromNode,
mapperDraftFromNode,
} from '../utils';
function shouldSendMappers(
snap: DraftGroup | undefined,
group: DraftGroup,
): boolean {
if (!snap) {
return true;
}
return !isEqual(snap.mappers, group.mappers);
}
export function buildTestGroups(
snapshot: DraftGroup[],
draft: DraftGroup[],
): SpantypesPostableSpanMapperTestGroupDTO[] {
const snapById = new Map(
snapshot
.filter((group) => group.serverId)
.map((group) => [group.serverId as string, group]),
);
return draft.map((group) => {
const base = buildPostableGroup(groupDraftFromNode(group));
const snap = group.serverId ? snapById.get(group.serverId) : undefined;
return {
...base,
mappers: shouldSendMappers(snap, group)
? group.mappers.map((mapper) =>
buildPostableMapper(mapperDraftFromNode(mapper)),
)
: null,
};
});
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
const keys = Object.keys(parsed);
return (
keys.length > 0 &&
keys.every((key) => key === 'attributes' || key === 'resource') &&
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
);
}
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {
const trimmed = input.trim();
if (!trimmed) {
throw new Error('Paste a JSON span object to run the test.');
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new Error(
'Invalid JSON — check for trailing commas or missing quotes.',
);
}
if (!isPlainObject(parsed)) {
throw new Error('Span must be a JSON object of attribute key-value pairs.');
}
if (isSpanEnvelope(parsed)) {
return {
attributes: isPlainObject(parsed.attributes) ? parsed.attributes : {},
resource: isPlainObject(parsed.resource) ? parsed.resource : {},
};
}
return { attributes: parsed, resource: {} };
}
export function buildTestRequest(
snapshot: DraftGroup[],
draft: DraftGroup[],
input: string,
): SpantypesPostableSpanMapperTestDTO {
return {
groups: buildTestGroups(snapshot, draft),
spans: [parseSpanInput(input)],
};
}
export type AttrChangeStatus = 'added' | 'changed' | 'unchanged' | 'removed';
export interface AttrDiffEntry {
key: string;
value: unknown;
status: AttrChangeStatus;
}
// Renders a diff value for display: strings as-is, everything else JSON-encoded.
export function formatAttributeValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
export function diffAttributeMaps(
inputAttributes: Record<string, unknown>,
resultAttributes: Record<string, unknown>,
): AttrDiffEntry[] {
const added: AttrDiffEntry[] = [];
const changed: AttrDiffEntry[] = [];
const unchanged: AttrDiffEntry[] = [];
const removed: AttrDiffEntry[] = [];
Object.entries(resultAttributes).forEach(([key, value]) => {
if (!(key in inputAttributes)) {
added.push({ key, value, status: 'added' });
} else if (!isEqual(inputAttributes[key], value)) {
changed.push({ key, value, status: 'changed' });
} else {
unchanged.push({ key, value, status: 'unchanged' });
}
});
Object.entries(inputAttributes).forEach(([key, value]) => {
if (!(key in resultAttributes)) {
removed.push({ key, value, status: 'removed' });
}
});
return [...added, ...changed, ...unchanged, ...removed];
}

View File

@@ -1,155 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useTestSpanMappers } from 'api/generated/services/spanmapper';
import { AxiosError } from 'axios';
import { debounce } from 'lodash-es';
import { buildTestRequest, parseSpanInput } from './testPayload';
import {
clearStoredSpanInput,
getStoredSpanInput,
SAMPLE_SPAN_JSON,
setStoredSpanInput,
} from './spanInputStorage';
import { DraftGroup } from '../types';
export type TestTabAttributes = Record<string, unknown>;
export type TestTabResource = Record<string, unknown>;
const PERSIST_DEBOUNCE_MS = 500;
function apiErrorMessage(error: unknown): string {
const axiosError = error as AxiosError<RenderErrorResponseDTO>;
return (
axiosError?.response?.data?.error?.message ??
(error instanceof Error ? error.message : 'Test failed. Please try again.')
);
}
export interface UseTestSpanMapper {
input: string;
setInput: (value: string) => void;
run: () => void;
reset: () => void;
resetToTemplate: () => void;
isTemplateInput: boolean;
isRunning: boolean;
validationError: string | null;
result: SpantypesSpanMapperTestSpanDTO[] | null;
testedAttributes: TestTabAttributes | null;
testedResource: TestTabResource | null;
error: string | null;
}
export function useTestSpanMapper(
snapshot: DraftGroup[],
draft: DraftGroup[],
): UseTestSpanMapper {
const [input, setInputValue] = useState<string>(getStoredSpanInput);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SpantypesSpanMapperTestSpanDTO[] | null>(
null,
);
const [testedAttributes, setTestedAttributes] =
useState<TestTabAttributes | null>(null);
const [testedResource, setTestedResource] = useState<TestTabResource | null>(
null,
);
const { mutate, isLoading } = useTestSpanMappers();
const persistInput = useMemo(
() => debounce(setStoredSpanInput, PERSIST_DEBOUNCE_MS),
[],
);
useEffect(
() => (): void => {
persistInput.flush();
},
[persistInput],
);
const setInput = useCallback(
(value: string): void => {
setInputValue(value);
persistInput(value);
},
[persistInput],
);
const validationError = useMemo((): string | null => {
try {
parseSpanInput(input);
return null;
} catch (err) {
return err instanceof Error ? err.message : 'Invalid span JSON.';
}
}, [input]);
const reset = useCallback((): void => {
setError(null);
setResult(null);
setTestedAttributes(null);
setTestedResource(null);
}, []);
const resetToTemplate = useCallback((): void => {
persistInput.cancel();
clearStoredSpanInput();
setInputValue(SAMPLE_SPAN_JSON);
reset();
}, [persistInput, reset]);
const isTemplateInput = input.trim() === SAMPLE_SPAN_JSON;
const run = useCallback((): void => {
reset();
let body;
try {
body = buildTestRequest(snapshot, draft, input);
} catch (parseError) {
setError(apiErrorMessage(parseError));
return;
}
const submittedSpan = body.spans?.[0];
const submittedAttributes = (submittedSpan?.attributes ??
{}) as TestTabAttributes;
const submittedResource = (submittedSpan?.resource ?? {}) as TestTabResource;
mutate(
{ data: body },
{
onSuccess: (response) => {
setTestedAttributes(submittedAttributes);
setTestedResource(submittedResource);
setResult(response.data?.spans ?? []);
},
onError: (mutationError) => {
setResult(null);
setError(apiErrorMessage(mutationError));
},
},
);
}, [snapshot, draft, input, mutate, reset]);
return {
input,
setInput,
run,
reset,
resetToTemplate,
isTemplateInput,
isRunning: isLoading,
validationError,
result,
testedAttributes,
testedResource,
error,
};
}

View File

@@ -1,32 +1,6 @@
import { rest, server } from 'mocks-server/server';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
// The header's Save/Discard and the group toggle are Admin-gated via useAuthZ;
// render as an admin so those controls are present.
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockedUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
// Monaco can't run in jsdom — stand in a textarea so the span input is editable.
jest.mock('@monaco-editor/react', () => ({
__esModule: true,
default: ({
value,
onChange,
}: {
value: string;
onChange: (next?: string) => void;
}): JSX.Element => (
<textarea
aria-label="span-json-editor"
data-testid="span-json-editor"
value={value}
onChange={(event): void => onChange(event.target.value)}
/>
),
}));
import LLMObservabilityAttributeMapping from '../LLMObservabilityAttributeMapping';
import { GROUPS_ENDPOINT, makeGroupsResponse, mockGroups } from './fixtures';
@@ -42,7 +16,6 @@ describe('LLMObservabilityAttributeMapping', () => {
beforeEach(() => {
window.history.pushState(null, '', '/');
setupGroups();
mockedUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
@@ -113,26 +86,6 @@ describe('LLMObservabilityAttributeMapping', () => {
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
});
it('keeps the sample span input when switching away from the test tab and back', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const editor = await screen.findByTestId('span-json-editor');
await user.clear(editor);
await user.type(editor, '{{ "my.attr": 1 }');
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
await screen.findByTestId('attribute-mappings-tab');
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: 'Test' }));
await expect(screen.findByTestId('span-json-editor')).resolves.toHaveValue(
'{ "my.attr": 1 }',
);
});
it('keeps staged changes when the discard prompt is dismissed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservabilityAttributeMapping />);

View File

@@ -1,7 +1,6 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
@@ -17,13 +16,12 @@ function AttributeMappingHeader({
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{canManage && isDirty && (
{isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes

View File

@@ -35,7 +35,6 @@ function clone(groups: DraftGroup[]): DraftGroup[] {
export interface AttributeMappingEditor {
groups: DraftGroup[];
snapshot: DraftGroup[];
isLoading: boolean;
isError: boolean;
isDirty: boolean;
@@ -305,7 +304,6 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
return {
groups: draft ?? [],
snapshot,
isLoading: !ready || draft === null,
isError: groupsQuery.isError,
isDirty,

View File

@@ -1,9 +0,0 @@
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
const ADMIN_PERMISSION = [IsAdminPermission];
export function useCanManageAttributeMapping(): boolean {
const { allowed } = useAuthZ(ADMIN_PERMISSION);
return allowed;
}

View File

@@ -1,97 +0,0 @@
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../variableFormModel';
import { dtoToFormModel, formModelToDto } from '../variableAdapters';
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), name: 'v', ...overrides };
}
/** The list spec a saved variable carries, whatever its plugin. */
function listSpec(m: VariableFormModel): {
allowMultiple?: boolean;
allowAllValue?: boolean;
} {
return formModelToDto(m).spec as {
allowMultiple?: boolean;
allowAllValue?: boolean;
};
}
describe('formModelToDto — the ALL flag needs multi-select', () => {
it('keeps ALL for a multi-select dynamic variable', () => {
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: true,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: true });
});
it('drops ALL for a single-select dynamic variable', () => {
// The API rejects allowAllValue without allowMultiple, and this used to be forced
// true for every dynamic variable — which blocked saving the whole dashboard.
expect(
listSpec(
model({
type: 'DYNAMIC',
dynamicAttribute: 'host.name',
multiSelect: false,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('drops ALL for a single-select query variable that still carries the flag', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: false,
showAllOption: true,
}),
),
).toMatchObject({ allowMultiple: false, allowAllValue: false });
});
it('respects the toggle on a multi-select query variable', () => {
expect(
listSpec(
model({
type: 'QUERY',
queryValue: 'SELECT 1',
multiSelect: true,
showAllOption: false,
}),
),
).toMatchObject({ allowMultiple: true, allowAllValue: false });
});
it('round-trips a single-select dynamic variable unchanged', () => {
// What the dashboard in the report holds: saving it must not flip the flag.
const dto = {
kind: 'ListVariable',
spec: {
name: 'host.name',
display: { name: 'host.name', description: '' },
allowMultiple: false,
allowAllValue: false,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'host.name', signal: 'all' },
},
},
} as never;
expect(listSpec(dtoToFormModel(dto))).toMatchObject({
allowMultiple: false,
allowAllValue: false,
});
});
});

View File

@@ -133,14 +133,9 @@ export function formModelToDto(
name: model.name,
display,
allowMultiple: model.multiSelect,
// Dynamic variables always expose the aggregate "ALL" entry (matches V1, which
// forced showALLOption true on save); other types respect the toggle. Either
// way it needs multi-select — ALL is a set of values, and the API rejects the
// flag without it, which used to make a single-select dynamic variable
// unsaveable and blocked every other edit to the dashboard with it.
allowAllValue:
model.multiSelect &&
(model.type === 'DYNAMIC' ? true : model.showAllOption),
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
// which forced showALLOption true on save); other types respect the toggle.
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
sort: model.sort,
defaultValue: model.defaultValue,

View File

@@ -13,7 +13,6 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import { useVariableSelection } from './hooks/useVariableSelection';
import { resolveDefaultSelection } from './utils/resolveVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
@@ -97,10 +96,12 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
variable={variable}
variables={variables}
selections={selection}
// Until the seed commits a selection, stand in the same default it will
// commit, through the one resolver — an empty stand-in reads as "nothing
// selected" to a control that snapshots it on mount.
selection={selection[variable.name] ?? resolveDefaultSelection(variable)}
selection={
selection[variable.name] ?? {
value: variable.multiSelect ? [] : '',
allSelected: false,
}
}
onChange={(next): void => setSelection(variable.name, next)}
onAutoSelect={(next): void => autoSelect(variable.name, next)}
/>

View File

@@ -1,92 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { VariableSelection } from '../selectionTypes';
import TextSelector from '../components/selectors/TextSelector';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
function renderSelector(
selection: VariableSelection,
onChange = jest.fn(),
): { rerender: (selection: VariableSelection) => void; onChange: jest.Mock } {
const { rerender } = render(
<TextSelector
selection={selection}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
);
return {
onChange,
rerender: (next): void =>
rerender(
<TextSelector
selection={next}
defaultValue="flower"
onChange={onChange}
testId="variable-input-service"
/>,
),
};
}
function input(): HTMLInputElement {
return screen.getByTestId('variable-input-service') as HTMLInputElement;
}
describe('TextSelector', () => {
it('shows the value the seed commits after mount', () => {
// The bar mounts before the seed resolves the definition's default, so the first
// render sees nothing selected. The box must follow the value once it lands.
const { rerender } = renderSelector({ value: '', allSelected: false });
rerender({ value: 'flower', allSelected: false });
expect(input().value).toBe('flower');
});
it('follows a selection replaced from outside (share link, reset)', () => {
const { rerender } = renderSelector({ value: 'flower', allSelected: false });
rerender({ value: 'rose', allSelected: false });
expect(input().value).toBe('rose');
});
it('keeps what the user types until they commit it', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'flower', allSelected: false });
await user.clear(input());
await user.type(input(), 'tulip');
// Still local — a keystroke must not cascade to dependent panels.
expect(input().value).toBe('tulip');
expect(onChange).not.toHaveBeenCalled();
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'tulip',
allSelected: false,
});
});
it('restores the default when emptied', async () => {
const user = userEvent.setup();
const { onChange } = renderSelector({ value: 'tulip', allSelected: false });
await user.clear(input());
await user.tab();
expect(onChange).toHaveBeenCalledWith({
value: 'flower',
allSelected: false,
});
expect(input().value).toBe('flower');
});
});

View File

@@ -87,99 +87,4 @@ describe('ValueSelector', () => {
expect(screen.getByText('ALL')).toBeInTheDocument();
});
describe('an ALL selection whose options are still loading', () => {
it('reads ALL, not the "Select value" placeholder', () => {
// Options arrive after the selection is known (first fetch, or a dynamic ALL,
// which carries no values at all) — the control must not read as unselected.
renderSelector({ value: null, allSelected: true }, [], true);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toHaveTextContent('ALL');
});
it('still shows concrete values while options load', () => {
renderSelector(
{ value: ['checkout-service-prod'], allSelected: false },
[],
true,
);
expect(
document.querySelector('.ant-select-selection-placeholder'),
).toBeNull();
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
expect(clearIcon()).toBeNull();
});
it('offers it once the list is open', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await openDropdown();
expect(clearIcon()).not.toBeNull();
});
it('offers no clear icon while every option is selected', async () => {
// ALL is every option, so there is nothing to clear — and the shared control
// refuses to empty an ALL selection, which would leave the icon inert.
renderSelector({ value: OPTIONS, allSelected: true }, OPTIONS);
await openDropdown();
expect(clearIcon()).toBeNull();
});
it('empties the list and commits nothing until it closes', async () => {
const onChange = jest.fn();
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="query"
multiSelect
showAllOption
selection={{ value: VALUES, allSelected: false }}
onChange={onChange}
emptyFallback={{ value: [OPTIONS[0]], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
await openDropdown();
await user.click(clearIcon() as Element);
expect(document.querySelectorAll('.ant-select-selection-item')).toHaveLength(
0,
);
expect(onChange).not.toHaveBeenCalled();
// Closing fills in whatever the variable should hold.
await user.keyboard('{Escape}');
expect(onChange).toHaveBeenCalledWith({
value: [OPTIONS[0]],
allSelected: false,
});
});
});
});

View File

@@ -61,47 +61,6 @@ describe('useAutoSelect', () => {
expect(next).toBeUndefined();
});
it('selects ALL for an ALL-enabled multi-select with nothing selected', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['a', 'b'], allSelected: true });
});
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 },
);
expect(next).toStrictEqual({ value: ['x', 'y'], allSelected: true });
});
it('selects the ALL sentinel for an empty ALL-enabled dynamic multi-select', () => {
const next = run(
model({ type: 'DYNAMIC', multiSelect: true, showAllOption: true }),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: null, allSelected: true });
});
it('still honours a configured default over ALL', () => {
const next = run(
model({
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
['a', 'b'],
{ value: [], allSelected: false },
);
expect(next).toStrictEqual({ value: ['b'], allSelected: false });
});
it('keeps the still-valid subset of a multi-select when options re-scope', () => {
const next = run(
model({ type: 'QUERY', multiSelect: true }),

View File

@@ -97,125 +97,6 @@ describe('useSeedVariableSelection', () => {
});
});
it('prefers the configured default over a stored empty text value', () => {
// Persisted from a session where the box was never filled — the default the
// definition carries must win, or the variable reads as unset forever.
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: '', allSelected: false } });
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT', textValue: 'prod' }),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: 'prod',
allSelected: false,
});
});
it('defaults an ALL-enabled multi-select to ALL over a stored empty array', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: [], allSelected: false } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
// Custom options need no request, so ALL is materialized here rather than left as
// a flag for the post-fetch reconcile to expand.
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b'],
allSelected: true,
});
});
it('materializes ALL for a custom variable so nothing has to reconcile it later', () => {
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b,c',
multiSelect: true,
showAllOption: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a', 'b', 'c'],
allSelected: true,
});
});
it('drops values a custom variable no longer offers, at seed time', () => {
useDashboardStore.getState().setVariableValues('d1', {
env: { value: ['a', 'gone'], allSelected: false },
});
const dash = dashboard('d1', [
model({
name: 'env',
type: 'CUSTOM',
customValue: 'a,b',
multiSelect: true,
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: ['a'],
allSelected: false,
});
});
it('keeps a stored ALL selection that has not materialized yet', () => {
useDashboardStore
.getState()
.setVariableValues('d1', { env: { value: null, allSelected: true } });
const dash = dashboard('d1', [
model({
name: 'env',
type: 'QUERY',
multiSelect: true,
showAllOption: true,
defaultValue: 'b',
}),
]);
renderHook(() => useSeedVariableSelection(dash));
expect(seededValue('d1', 'env')).toStrictEqual({
value: null,
allSelected: true,
});
});
it('does not rewrite the store when the effect re-runs with the same values', () => {
// A dashboard refetch or any spec edit hands back an equal-but-new variables array,
// re-running the seed. Writing an identical map would re-render every subscriber.
const variable = model({ name: 'env', type: 'TEXT', textValue: 'prod' });
const { rerender } = renderHook(
({ dash }) => useSeedVariableSelection(dash),
{ initialProps: { dash: dashboard('d1', [variable]) } },
);
const afterSeed = useDashboardStore.getState().variableValues;
rerender({ dash: dashboard('d1', [{ ...variable }]) });
expect(useDashboardStore.getState().variableValues).toBe(afterSeed);
});
it('initializes the fetch context with idle states for every variable', () => {
const dash = dashboard('d1', [
model({ name: 'env', type: 'TEXT' }),

View File

@@ -1,152 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useVariableSelection } from '../hooks/useVariableSelection';
jest.mock('nuqs', () => ({
parseAsJson: (): unknown => ({ withOptions: (): unknown => ({}) }),
useQueryState: (): unknown => [null, jest.fn()],
}));
jest.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown): unknown =>
selector({
globalTime: { minTime: 1, maxTime: 2, selectedTime: '5m' },
}),
}));
jest.mock('../../DashboardSettings/Variables/variableAdapters', () => ({
dtoToFormModel: (dto: unknown): unknown => dto,
}));
function model(overrides: Partial<VariableFormModel>): VariableFormModel {
return { ...emptyVariableFormModel(), ...overrides };
}
// env → svc, so a cascade off `env` is observable as a cycle bump on `svc`.
const env = model({
name: 'env',
type: 'QUERY',
multiSelect: true,
queryValue: 'SELECT env',
});
const svc = model({
name: 'svc',
type: 'QUERY',
queryValue: 'SELECT svc WHERE env = $env',
});
const dashboard = {
id: 'd1',
spec: { variables: [env, svc] },
} as unknown as DashboardtypesGettableDashboardV2DTO;
function svcCycleId(): number {
return useDashboardStore.getState().variableCycleIds.svc ?? 0;
}
describe('useVariableSelection — setSelection', () => {
beforeEach(() => {
useDashboardStore.setState({
variableValues: {},
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
variableFetchContext: null,
lastFetchAllKey: null,
});
});
it('does not re-cascade when the picked value is the one already held', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
const afterFirstPick = svcCycleId();
// Same values, then the same set in a different order: neither is a change, so
// the dependent must not be re-fetched.
act(() => {
result.current.setSelection('env', {
value: ['a', 'b'],
allSelected: false,
});
});
act(() => {
result.current.setSelection('env', {
value: ['b', 'a'],
allSelected: false,
});
});
expect(svcCycleId()).toBe(afterFirstPick);
});
it('ignores an auto-fill that the store already satisfies', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
// What a selector's first-render reconcile produces before the seed commits: a
// value the store then resolves to on its own.
await act(async () => {
result.current.autoSelect('env', { value: ['a'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(svcCycleId()).toBe(before);
});
it('still applies an auto-fill that changes the value', async () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
await act(async () => {
result.current.autoSelect('env', { value: ['a', 'b'], allSelected: false });
await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'b'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
it('still cascades a genuine change', () => {
const { result } = renderHook(() => useVariableSelection(dashboard));
act(() => {
result.current.setSelection('env', { value: ['a'], allSelected: false });
});
const before = svcCycleId();
act(() => {
result.current.setSelection('env', {
value: ['a', 'c'],
allSelected: false,
});
});
expect(useDashboardStore.getState().variableValues.d1?.env).toStrictEqual({
value: ['a', 'c'],
allSelected: false,
});
expect(svcCycleId()).toBe(before + 1);
});
});

View File

@@ -9,7 +9,6 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../../selectionTypes';
import { textDefault } from '../../utils/resolveVariableSelection';
import { computeVariableDependencies } from '../../utils/variableDependencies';
import TextSelector from '../selectors/TextSelector';
import VariableValueControl from '../selectors/VariableValueControl';
@@ -66,7 +65,7 @@ function VariableSelector({
variable.type === 'TEXT' ? (
<TextSelector
selection={selection}
defaultValue={textDefault(variable)}
defaultValue={variable.textValue}
onChange={onChange}
testId={`variable-input-${variable.name}`}
/>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import type { InputRef } from 'antd';
// eslint-disable-next-line signoz/no-antd-components -- match V1 textbox behaviour (commit on blur/Enter, borderless)
import { Input } from 'antd';
@@ -31,17 +31,6 @@ function TextSelector({
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
// The committed value can land after this input mounts — the seed resolves the
// definition's default one tick later, and a share link or a reset can replace it
// at any point. Without following it the box would keep showing its first render
// (empty, since nothing is seeded yet) until the user focused and blurred it.
// Typing never touches the selection, so an edit in progress cannot be interrupted.
useEffect(() => {
setValue(
typeof selection.value === 'string' ? selection.value : (defaultValue ?? ''),
);
}, [selection.value, defaultValue]);
const commit = useCallback(
(next: string): void => {
void logEvent(

View File

@@ -54,26 +54,11 @@ function ValueSelector({
[selection, options],
);
// That "all" path needs the options, so an ALL selection whose options have not
// arrived yet has nothing to render and the control would read "Select value"
// while it spins — as if nothing were selected. Say ALL in that slot instead: the
// selection is known, only its options are pending. Display only, so it can never
// be committed as a value.
const isAllPendingOptions = selection.allSelected && options.length === 0;
// Buffer edits while the dropdown is open; the committed selection is shown
// when closed. This defers the dependent cascade to a single commit-on-close.
const [isOpen, setIsOpen] = useState(false);
const [draft, setDraft] = useState<string[]>(committedValues);
// ALL is every option, so there is nothing to clear — and the shared control refuses
// to empty an ALL selection anyway, which would leave the icon inert. Unchecking ALL
// in the list is the way out of it.
const draftIsAll =
showAllOption &&
options.length > 0 &&
options.every((option) => draft.includes(option));
const commit = (values: string[]): void => {
// CustomMultiSelect emits the full value set when ALL is picked.
const isAll =
@@ -109,11 +94,8 @@ function ValueSelector({
errorMessage={errorMessage}
onRetry={onRetry}
showSearch
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
// not visible.
allowClear={isOpen && !draftIsAll}
placeholder={isAllPendingOptions ? 'ALL' : 'Select value'}
allowClear
placeholder="Select value"
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
@@ -147,10 +129,12 @@ function ValueSelector({
void logEvent(DashboardDetailEvents.VariableMultiSelectCleared, {
variableType,
});
// Empties the list, committing nothing. Closing resolves an empty draft
// to whatever the variable should hold — its configured default, else ALL
// where it offers one, else the first option.
setDraft([]);
// A clear on the closed control falls back to the default immediately;
// while open it just empties the draft (committed on close).
if (!isOpen) {
onChange(emptyFallback);
}
}}
/>
);

View File

@@ -6,13 +6,7 @@ import { dtoToFormModel } from '../../DashboardSettings/Variables/variableAdapte
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
areSelectionsEqual,
reconcileWithOptions,
resolveDefaultSelection,
} from '../utils/resolveVariableSelection';
import { hasUsableValue } from '../utils/selectionUtils';
import { resolveDefaultSelection } from '../utils/resolveVariableSelection';
import type {
SelectedVariableValue,
VariableSelection,
@@ -24,44 +18,6 @@ import {
} from '../utils/variableDependencies';
import { ALL_SELECTED, variablesUrlParser } from '../utils/variablesUrlState';
function isStoredSelectionSet(
stored: VariableSelection,
model: VariableFormModel,
): boolean {
return !!stored.allSelected || hasUsableValue(stored, model.type);
}
/** Whether the seeded map matches, entry for entry, what the store already holds. */
function isSameSelection(
seeded: VariableSelectionMap,
current: VariableSelectionMap,
): boolean {
const names = Object.keys(seeded);
return (
names.length === Object.keys(current).length &&
names.every(
(name) => !!current[name] && areSelectionsEqual(seeded[name], current[name]),
)
);
}
/**
* A seeded value taken as far as it can go: for a variable whose options need no
* request, resolve against them now — ALL becomes the concrete array, values the list
* no longer offers are dropped. Left alone otherwise; the post-fetch reconcile does it
* once the options arrive.
*/
function resolveAgainstKnownOptions(
value: VariableSelection,
model: VariableFormModel,
): VariableSelection {
const options = knownVariableOptions(model);
if (options.length === 0) {
return value;
}
return reconcileWithOptions(model, value, options) ?? value;
}
// The `__ALL__` sentinel only means "ALL" for variables that support it — a
// legitimate value of "__ALL__" (e.g. a text var) is taken literally.
function fromUrlValue(
@@ -114,31 +70,22 @@ export function useSeedVariableSelection(
variables.forEach((variable) => {
const urlValue = urlValues?.[variable.name];
const stored = selection[variable.name];
const seed = (value: VariableSelection): void => {
seeded[variable.name] = resolveAgainstKnownOptions(value, variable);
};
if (urlValue !== undefined) {
const fromUrl = fromUrlValue(urlValue, variable);
// When the URL carries only the ALL sentinel but the store already holds
// the materialized full-option array, reuse it — avoids the re-fetch +
// re-materialize round-trip (and its dependent-refetch cascade) on load.
seed(
seeded[variable.name] =
fromUrl.allSelected && stored?.allSelected && Array.isArray(stored.value)
? stored
: fromUrl,
);
} else if (stored && isStoredSelectionSet(stored, variable)) {
seed(stored);
: fromUrl;
} else if (stored) {
seeded[variable.name] = stored;
} else {
seed(resolveDefaultSelection(variable));
seeded[variable.name] = resolveDefaultSelection(variable);
}
});
// This runs again whenever the spec's variable array changes identity — a refetch
// or any spec edit — and writing an identical map would re-render every selection
// subscriber for nothing.
if (!isSameSelection(seeded, selection)) {
setVariableValues(dashboardId, seeded);
}
setVariableValues(dashboardId, seeded);
// Read-once: a share link's `?variables=` seeds the store, then the param is
// dropped so the store is the sole source of truth. Selection changes never

View File

@@ -1,11 +1,14 @@
import { useEffect, useMemo } from 'react';
import logEvent from 'api/common/logEvent';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import { VARIABLE_TYPE_EVENT_LABEL } from '../../DashboardSettings/Variables/variableFormModel';
import {
sortValuesByOrder,
VARIABLE_TYPE_EVENT_LABEL,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../selectionTypes';
import { knownVariableOptions } from '../utils/knownVariableOptions';
import {
useFetchedVariableOptions,
type VariableOptions,
@@ -27,12 +30,14 @@ export function useVariableOptions(
): VariableOptions {
const fetched = useFetchedVariableOptions(variable, variables, selections);
// Keyed on the fields the parse actually reads, not the model identity: a dashboard
// refetch hands back an equal-but-new model, and a new options array would re-fire
// the post-fetch reconcile for nothing.
const customOptions = useMemo(
() => knownVariableOptions(variable),
// eslint-disable-next-line react-hooks/exhaustive-deps
() =>
variable.type === 'CUSTOM'
? sortValuesByOrder(
commaValuesParser(variable.customValue),
variable.sort,
).map(String)
: ([] as string[]),
[variable.type, variable.customValue, variable.sort],
);

View File

@@ -12,7 +12,6 @@ import type {
VariableSelection,
VariableSelectionMap,
} from '../selectionTypes';
import { areSelectionsEqual } from '../utils/resolveVariableSelection';
import { useSeedVariableSelection } from './useSeedVariableSelection';
/**
@@ -104,10 +103,6 @@ export function useVariableSelection(
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
const current = selectionRef.current[name];
if (current && areSelectionsEqual(next, current)) {
return;
}
setVariableValue(dashboardId, name, next);
enqueueDescendants(name);
},
@@ -129,19 +124,8 @@ export function useVariableSelection(
if (names.length === 0 || !dashboardId) {
return;
}
// A fill can arrive already satisfied: a selector reconciles against the options
// on its first render, before the seed has committed, and the seed then resolves
// to the same value. Refresh only what actually moved, so a settled load ends
// without a write or a dependent refetch.
const current = selectionRef.current;
const changed = names.filter(
(name) => !current[name] || !areSelectionsEqual(fills[name], current[name]),
);
if (changed.length === 0) {
return;
}
setVariableValues(dashboardId, { ...current, ...fills });
enqueueDescendantsBatch(changed);
setVariableValues(dashboardId, { ...selectionRef.current, ...fills });
enqueueDescendantsBatch(names);
}, [dashboardId, setVariableValues, enqueueDescendantsBatch]);
const autoSelect = useCallback(

View File

@@ -1,23 +0,0 @@
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import { sortValuesByOrder } from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableFormModel } from '../../DashboardSettings/Variables/variableFormModel';
/**
* The options of a variable whose list needs no request — a CUSTOM variable's comma
* list. Empty for QUERY and DYNAMIC, whose options only exist once fetched, and for
* TEXT, which has none.
*
* Knowing them up front lets the seed resolve such a variable's value completely
* (materializing ALL, dropping values the list no longer offers) instead of leaving
* that to the post-fetch reconcile, which would cost a second store write and a
* refetch of everything downstream.
*/
export function knownVariableOptions(model: VariableFormModel): string[] {
if (model.type !== 'CUSTOM') {
return [];
}
return sortValuesByOrder(commaValuesParser(model.customValue), model.sort).map(
String,
);
}

View File

@@ -37,7 +37,7 @@ function firstConfiguredDefault(model: VariableFormModel): string | undefined {
}
/** A TEXT variable's default: its configured default, else its textValue (always a string). */
export function textDefault(model: VariableFormModel): string {
function textDefault(model: VariableFormModel): string {
return firstConfiguredDefault(model) ?? model.textValue;
}
@@ -53,31 +53,15 @@ function isAllDefault(
);
}
/**
* The default selection for a variable with nothing usable selected: its configured
* default, else ALL for an ALL-enabled multi-select, else the first option.
*/
/** The configured default (or first option) as a fresh selection. */
function fillDefault(
model: VariableFormModel,
options: string[],
): VariableSelection {
const fallback = firstConfiguredDefault(model);
if (fallback && options.includes(fallback)) {
return {
value: model.multiSelect ? [fallback] : fallback,
allSelected: false,
};
}
// No usable configured default: an ALL-enabled multi-select defaults to ALL, not
// to an arbitrary first option — the same default the seed applies (keep in sync
// with resolveDefaultSelection).
if (model.multiSelect && model.showAllOption) {
return materializeAll(model, options, null) ?? ALL_SELECTION;
}
const initial = fallback && options.includes(fallback) ? fallback : options[0];
return {
value: model.multiSelect ? [options[0]] : options[0],
value: model.multiSelect ? [initial] : initial,
allSelected: false,
};
}

View File

@@ -1,956 +0,0 @@
{
"schemaVersion": "v6",
"image": "/assets/Logos/gcp-cloud-storage",
"name": "",
"generateName": true,
"tags": [
{
"key": "tag",
"value": "observability"
}
],
"spec": {
"display": {
"name": "GCP Cloud Storage Overview",
"description": "Dashboard for GCP Cloud Storage overview"
},
"variables": [
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "project_id",
"description": "GCP Project ID"
},
"allowAllValue": false,
"allowMultiple": false,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "project_id",
"signal": "metrics"
}
},
"name": "project_id"
}
},
{
"kind": "ListVariable",
"spec": {
"display": {
"name": "bucket_name",
"description": "GCS bucket name"
},
"allowAllValue": true,
"allowMultiple": true,
"customAllValue": "",
"capturingRegexp": "",
"sort": "none",
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {
"name": "bucket_name",
"signal": "metrics"
}
},
"name": "bucket_name"
}
}
],
"panels": {
"1f0a4d5b-6c2e-4a71-8b3d-9e5c07a41d20": {
"kind": "Panel",
"spec": {
"display": {
"name": "Bucket size",
"description": "Total size of all objects in the bucket, grouped by storage class."
},
"plugin": {
"kind": "signoz/TablePanel",
"spec": {
"visualization": {
"timePreference": "global_time"
},
"formatting": {
"columnUnits": {
"A": "By"
},
"decimalPrecision": "2"
},
"thresholds": null
}
},
"queries": [
{
"kind": "scalar",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/total_bytes",
"temporality": "",
"timeAggregation": "latest",
"spaceAggregation": "sum",
"reduceTo": "last"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "Size"
}
}
}
}
],
"links": []
}
},
"29ed9824-21db-44c3-9537-12be458f5c20": {
"kind": "Panel",
"spec": {
"display": {
"name": "Total object bytes",
"description": "Total size of all objects in the bucket, grouped by storage class and type."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/total_bytes",
"temporality": "",
"timeAggregation": "latest",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "type",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{storage_class}}/{{type}}"
}
}
}
}
],
"links": []
}
},
"cbc53174-5527-4bce-b32d-317403849e48": {
"kind": "Panel",
"spec": {
"display": {
"name": "Total object count",
"description": "Total number of objects and multipart-uploads per bucket, grouped by storage class and type, where type can be live-object, noncurrent-object, soft-deleted-object or multipart-upload."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "{count}",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/total_count",
"temporality": "",
"timeAggregation": "latest",
"spaceAggregation": "sum",
"reduceTo": "avg"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "type",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{storage_class}}/{{type}}"
}
}
}
}
],
"links": []
}
},
"76e70ce5-47e4-4b5b-9715-49fa21c58b0c": {
"kind": "Panel",
"spec": {
"display": {
"name": "Deleted bytes",
"description": "Rate of deleted bytes per bucket, grouped by storage class."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/storage/v2/deleted_bytes",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "storage_class",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{storage_class}}"
}
}
}
}
],
"links": []
}
},
"c5af2813-34ec-41a9-8d53-c9acd67f569f": {
"kind": "Panel",
"spec": {
"display": {
"name": "Request count",
"description": "Rate of API calls, grouped by the API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "{count}/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/api/request_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
},
"70333210-68c3-4387-a6b7-afa2459d27dd": {
"kind": "Panel",
"spec": {
"display": {
"name": "Authentication count",
"description": "Rate of HMAC/RSA signed requests, grouped by authentication method, API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "{count}/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/authn/authentication_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "authentication_method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{authentication_method}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
},
"7a98084c-2cd3-4146-a831-f5382162ffe5": {
"kind": "Panel",
"spec": {
"display": {
"name": "Received bytes",
"description": "Rate of bytes received over the network, grouped by the API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/network/received_bytes_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
},
"7138bf98-b41a-46d8-90ef-8e7470819cdf": {
"kind": "Panel",
"spec": {
"display": {
"name": "Sent bytes",
"description": "Rate of bytes sent over the network, grouped by the API method name and response code."
},
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "global_time",
"fillSpans": false
},
"formatting": {
"unit": "By/s",
"decimalPrecision": "2"
},
"chartAppearance": {
"lineInterpolation": "spline",
"showPoints": false,
"lineStyle": "solid",
"fillMode": "none",
"spanGaps": {
"fillOnlyBelow": false,
"fillLessThan": ""
}
},
"axes": {
"softMin": 0,
"softMax": 0,
"isLogScale": false
},
"legend": {
"position": "right",
"mode": "list",
"customColors": null
},
"thresholds": null
}
},
"queries": [
{
"kind": "time_series",
"spec": {
"name": "A",
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"source": "",
"aggregations": [
{
"metricName": "storage.googleapis.com/network/sent_bytes_count",
"temporality": "",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"disabled": false,
"filter": {
"expression": "project_id = $project_id AND bucket_name in $bucket_name AND gcp.resource_type = 'gcs_bucket'"
},
"groupBy": [
{
"name": "bucket_name",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "method",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
},
{
"name": "response_code",
"signal": "",
"fieldContext": "attribute",
"fieldDataType": "string"
}
],
"order": [],
"having": {
"expression": ""
},
"functions": [],
"legend": "{{bucket_name}}/{{method}} - {{response_code}}"
}
}
}
}
],
"links": []
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/1f0a4d5b-6c2e-4a71-8b3d-9e5c07a41d20"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Storage",
"collapse": {
"open": true
}
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/29ed9824-21db-44c3-9537-12be458f5c20"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/cbc53174-5527-4bce-b32d-317403849e48"
}
},
{
"x": 0,
"y": 6,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/76e70ce5-47e4-4b5b-9715-49fa21c58b0c"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "API & Authentication",
"collapse": {
"open": true
}
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/c5af2813-34ec-41a9-8d53-c9acd67f569f"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/70333210-68c3-4387-a6b7-afa2459d27dd"
}
}
]
}
},
{
"kind": "Grid",
"spec": {
"display": {
"title": "Network",
"collapse": {
"open": true
}
},
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/7a98084c-2cd3-4146-a831-f5382162ffe5"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/7138bf98-b41a-46d8-90ef-8e7470819cdf"
}
}
]
}
}
],
"duration": "",
"refreshInterval": "",
"links": []
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#aecbfa;}.cls-2{fill:#669df6;}.cls-3{fill:#4285f4;}.cls-4{fill:#fff;}</style></defs><title>Icon_24px_CloudStorage_Color</title><g data-name="Product Icons"><rect class="cls-1" x="2" y="4" width="20" height="7"/><rect class="cls-2" x="20" y="4" width="2" height="7"/><polygon class="cls-3" points="22 4 20 4 20 11 22 4"/><rect class="cls-2" x="2" y="4" width="2" height="7"/><rect class="cls-4" x="6" y="7" width="6" height="1"/><rect class="cls-4" x="15" y="6" width="3" height="3" rx="1.5"/><rect class="cls-1" x="2" y="13" width="20" height="7"/><rect class="cls-2" x="20" y="13" width="2" height="7"/><polygon class="cls-3" points="22 13 20 13 20 20 22 13"/><rect class="cls-2" x="2" y="13" width="2" height="7"/><rect class="cls-4" x="6" y="16" width="6" height="1"/><rect class="cls-4" x="15" y="15" width="3" height="3" rx="1.5"/></g></svg>

Before

Width:  |  Height:  |  Size: 958 B

View File

@@ -1,70 +0,0 @@
{
"id": "cloudstorage",
"title": "GCP Cloud Storage",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "storage.googleapis.com/storage/v2/total_bytes",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "storage.googleapis.com/storage/v2/total_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "storage.googleapis.com/storage/v2/deleted_bytes",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/api/request_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/network/received_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/network/sent_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "storage.googleapis.com/authn/authentication_count",
"unit": "Count",
"type": "Sum",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Cloud Storage Overview",
"description": "Overview of GCP Cloud Storage metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor GCP Cloud Storage with SigNoz
Collect key GCP Cloud Storage metrics and view them with an out of the box dashboard.

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><defs><style>.cls-1{fill:#4285f4;}.cls-1,.cls-2,.cls-4{fill-rule:evenodd;}.cls-2{fill:#669df6;}.cls-3,.cls-4{fill:#aecbfa;}</style></defs><title>Icon_24px_K8Engine_Color</title><g data-name="Product Icons"><g ><polygon class="cls-1" points="14.68 13.06 19.23 15.69 19.23 16.68 14.29 13.83 14.68 13.06"/><polygon class="cls-2" points="9.98 13.65 4.77 16.66 4.45 15.86 9.53 12.92 9.98 13.65"/><rect class="cls-3" x="11.55" y="3.29" width="0.86" height="5.78"/><path class="cls-4" d="M3.25,7V17L12,22l8.74-5V7L12,2Zm15.63,8.89L12,19.78,5.12,15.89V8.11L12,4.22l6.87,3.89v7.78Z"/><polygon class="cls-4" points="11.98 11.5 15.96 9.21 11.98 6.91 8.01 9.21 11.98 11.5"/><polygon class="cls-2" points="11.52 12.3 7.66 10.01 7.66 14.6 11.52 16.89 11.52 12.3"/><polygon class="cls-1" points="12.48 12.3 12.48 16.89 16.34 14.6 16.34 10.01 12.48 12.3"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 941 B

View File

@@ -1,124 +0,0 @@
{
"id": "gke",
"title": "GCP Kubernetes Engine",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "kubernetes.io/container/cpu/limit_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/cpu/request_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/memory/limit_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/memory/request_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/container/restart_count",
"unit": "Count",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/pod/latencies/pod_first_ready",
"unit": "Seconds",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/pod/network/received_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/pod/network/sent_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/pod/volume/utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/cpu/allocatable_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/memory/allocatable_utilization",
"unit": "None",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/ephemeral_storage/used_bytes",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/ephemeral_storage/allocatable_bytes",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "kubernetes.io/node/network/received_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/node/network/sent_bytes_count",
"unit": "Bytes",
"type": "Sum",
"description": ""
},
{
"name": "kubernetes.io/node/status_condition",
"unit": "None",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {
"gcp": {}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "GCP Kubernetes Engine Overview",
"description": "Overview of GCP Kubernetes Engine metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -1,3 +0,0 @@
### Monitor GCP Kubernetes Engine with SigNoz
Collect key GCP Kubernetes Engine metrics and view them with an out of the box dashboard.

View File

@@ -213,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()", Alias: "spanCount"},
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIRequestModel,
Name: telemetrytypes.GenAIRequestModel,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIProviderName,
Name: telemetrytypes.GenAIProviderName,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
@@ -254,9 +254,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
switch c.Type {
case qbtypes.ColumnTypeGroup:
switch c.Name {
case llmpricingruletypes.GenAIRequestModel:
case telemetrytypes.GenAIRequestModel:
modelIdx = i
case llmpricingruletypes.GenAIProviderName:
case telemetrytypes.GenAIProviderName:
providerIdx = i
}
case qbtypes.ColumnTypeAggregation:

View File

@@ -249,7 +249,7 @@ func (handler *handler) ReplaceVariables(rw http.ResponseWriter, req *http.Reque
errs := []error{}
for idx, item := range queryRangeRequest.CompositeQuery.Queries {
if item.Type == qbtypes.QueryTypeBuilder {
if item.Type == qbtypes.QueryTypeBuilder || item.Type == qbtypes.QueryTypeBuilderAI {
switch spec := item.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if spec.Filter != nil && spec.Filter.Expression != "" {

View File

@@ -249,6 +249,7 @@ func (q *querier) buildPreviewProviders(
func rendersStandaloneStatement(t qbtypes.QueryType) bool {
switch t {
case qbtypes.QueryTypeBuilder,
qbtypes.QueryTypeBuilderAI,
qbtypes.QueryTypePromQL,
qbtypes.QueryTypeClickHouseSQL,
qbtypes.QueryTypeTraceOperator:

View File

@@ -52,6 +52,7 @@ type querier struct {
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation]
@@ -71,6 +72,7 @@ func New(
metadataStore telemetrytypes.MetadataStore,
promEngine prometheus.Prometheus,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
@@ -92,6 +94,7 @@ func New(
metadataStore: metadataStore,
promEngine: promEngine,
traceStmtBuilder: traceStmtBuilder,
aiTraceStmtBuilder: aiTraceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
metricStmtBuilder: metricStmtBuilder,
@@ -242,6 +245,16 @@ func (q *querier) buildQueries(
}
queries[traceOpQuery.Name] = toq
steps[traceOpQuery.Name] = traceOpQuery.StepInterval
case qbtypes.QueryTypeBuilderAI:
spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
}
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
@@ -308,6 +321,11 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
event.MetricsUsed = true
}
case qbtypes.QueryTypeBuilderAI:
filter := query.GetFilter()
event.FilterApplied = event.FilterApplied || (filter != nil && filter.Expression != "")
event.GroupByApplied = event.GroupByApplied || len(query.GetGroupBy()) > 0
event.TracesUsed = true
case qbtypes.QueryTypePromQL:
event.MetricsUsed = true
case qbtypes.QueryTypeTraceOperator:
@@ -870,7 +888,9 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
// Reuse the statement builder the original query was created with, so an AI
// query keeps its AI builder without re-deriving it from the spec.
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()
@@ -1227,6 +1247,8 @@ func (q *querier) adjustStepInterval(queries []qbtypes.QueryEnvelope, start, end
if qe.GetStepInterval().Seconds() == 0 {
qe.SetStepInterval(secondsStep(metricRecommended))
}
case qbtypes.QueryTypeBuilderAI:
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
case qbtypes.QueryTypeTraceOperator:
clampStep(qe, traceLogRecommended, traceLogMin, &warnings)
}

View File

@@ -49,6 +49,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -121,6 +122,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
&mockMetricStmtBuilder{},

View File

@@ -20,6 +20,7 @@ func NewFactory(
prometheus prometheus.Prometheus,
metadataStore telemetrytypes.MetadataStore,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
@@ -41,6 +42,7 @@ func NewFactory(
metadataStore,
prometheus,
traceStmtBuilder,
aiTraceStmtBuilder,
logStmtBuilder,
auditStmtBuilder,
metricStmtBuilder,

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/auditstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/meterstatementbuilder"
@@ -117,6 +118,8 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
ctx := context.Background()
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, flagger).New(ctx, providerSettings, cfg)
@@ -128,7 +131,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
bucketCache := querier.NewBucketCache(providerSettings, cache, 0, 0)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
require.NoError(t, err)

View File

@@ -41,6 +41,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
metricStmtBuilder,
@@ -75,6 +76,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // aiTraceStmtBuilder
logStmtBuilder,
nil, // auditStmtBuilder
nil, // metricStmtBuilder
@@ -110,6 +112,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
metadataStore,
nil, // prometheus
traceStmtBuilder,
nil, // aiTraceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder

View File

@@ -0,0 +1,176 @@
package querybuilder
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)
// SplitFilterForAggregates partitions a single filter expression into a span-level
// part (a WHERE over spans) and a trace-level part (a HAVING over per-trace
// aggregates), splitting on the top-level AND.
//
// A key is trace-level when it carries the trace field context (`trace.completion_tokens`)
// or, with no context, its bare name is in aggregateNames. Any other explicit context
// (`span.`, `resource.`, …) is span-level. Trace-level and span-level keys may be
// AND-combined (they run at different query stages) but not OR-combined; an OR that
// mixes the two is an error.
func SplitFilterForAggregates(query string, aggregateNames map[string]struct{}) (spanExpr string, havingExpr string, err error) {
if strings.TrimSpace(query) == "" {
return "", "", nil
}
tree, syntaxErrors := parseFilterQuery(query)
if len(syntaxErrors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d syntax errors while parsing the filter expression.",
len(syntaxErrors),
)
additionals := make([]string, 0, len(syntaxErrors))
for _, syntaxError := range syntaxErrors {
if syntaxError.Error() != "" {
additionals = append(additionals, syntaxError.Error())
}
}
// TODO: add troubleshooting link to the filter query syntax guide once it's published.
return "", "", combinedErrors.WithAdditional(additionals...)
}
s := filterSplitter{query: []rune(query), aggregateNames: aggregateNames}
s.visit(tree)
if s.mixed {
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"trace-level and span-level filters cannot be combined within an OR/NOT group; separate them with a top-level AND")
}
return strings.Join(s.span, " AND "), strings.Join(s.having, " AND "), nil
}
func parseFilterQuery(query string) (antlr.Tree, []*SyntaxErr) {
lexerErrorListener := NewErrorListener()
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrorListener)
parserErrorListener := NewErrorListener()
parser := grammar.NewFilterQueryParser(antlr.NewCommonTokenStream(lexer, 0))
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrorListener)
tree := parser.Query()
return tree, append(lexerErrorListener.SyntaxErrors, parserErrorListener.SyntaxErrors...)
}
// filterSplitter walks the parse tree once, flattening the top-level AND chain and
// routing each atom (a comparison, a NOT expression, or a whole multi-branch OR group)
// to the span or having bucket by the class of the keys it references.
type filterSplitter struct {
query []rune
aggregateNames map[string]struct{}
span []string
having []string
mixed bool
}
func (s *filterSplitter) visit(node antlr.Tree) {
switch n := node.(type) {
case *grammar.QueryContext:
if n.Expression() != nil {
s.visit(n.Expression())
}
case *grammar.ExpressionContext:
if n.OrExpression() != nil {
s.visit(n.OrExpression())
}
case *grammar.OrExpressionContext:
// a single branch is just an AND chain; multiple branches are a real OR, kept
// whole so a class-mixing OR can be rejected.
if ands := n.AllAndExpression(); len(ands) == 1 {
s.visit(ands[0])
} else {
s.route(n)
}
case *grammar.AndExpressionContext:
for _, u := range n.AllUnaryExpression() {
s.visit(u)
}
case *grammar.UnaryExpressionContext:
if n.NOT() != nil {
s.route(n)
} else if n.Primary() != nil {
s.visit(n.Primary())
}
case *grammar.PrimaryContext:
if n.OrExpression() != nil { // parenthesized sub-expression
s.visit(n.OrExpression())
} else {
s.route(n)
}
}
}
// route classifies an atom and appends its original source text to the right bucket.
func (s *filterSplitter) route(atom antlr.ParserRuleContext) {
isTrace, isSpan := classifyKeys(atom, s.aggregateNames)
if isTrace && isSpan {
s.mixed = true
return
}
text := atomSourceText(s.query, atom)
// A multi-branch OR group's source slice excludes its enclosing parens (they belong
// to the parent Primary). Re-wrap it so rejoining a bucket with " AND " cannot invert
// OR/AND precedence, e.g. `a AND (b OR c)` must not flatten to `a AND b OR c`.
if or, ok := atom.(*grammar.OrExpressionContext); ok && len(or.AllAndExpression()) > 1 {
text = "(" + text + ")"
}
if isTrace {
s.having = append(s.having, text)
} else {
s.span = append(s.span, text)
}
}
// classifyKeys reports whether a subtree references trace-level and/or span-level keys.
// A key is trace-level when it carries the trace field context or, with no context,
// its name is a known aggregate; an unknown name under the trace context stays
// trace-level so the aggregate validation rejects it with a targeted error. Any other
// explicit context (`span.`, `resource.`, …) is span-level.
func classifyKeys(node antlr.Tree, aggregateNames map[string]struct{}) (isTrace, isSpan bool) {
kc, ok := node.(*grammar.KeyContext)
if ok {
key := telemetrytypes.GetFieldKeyFromKeyText(kc.GetText())
switch key.FieldContext {
case telemetrytypes.FieldContextTrace:
isTrace = true
case telemetrytypes.FieldContextUnspecified:
_, isTrace = aggregateNames[key.Name]
isSpan = !isTrace
default:
isSpan = true
}
return
}
for i := 0; i < node.GetChildCount(); i++ {
t, s := classifyKeys(node.GetChild(i), aggregateNames)
isTrace = isTrace || t
isSpan = isSpan || s
}
return
}
// atomSourceText returns the original source substring for an atom, preserving
// whitespace. The token stream drops skipped whitespace, which would glue word
// operators (OR/AND/NOT) to their operands, so slice the input by token offsets.
// ANTLR offsets are rune indices (InputStream holds []rune), hence the rune slice.
func atomSourceText(query []rune, atom antlr.ParserRuleContext) string {
start, stop := atom.GetStart(), atom.GetStop()
if start == nil || stop == nil || start.GetStart() < 0 || stop.GetStop() >= len(query) || stop.GetStop() < start.GetStart() {
return atom.GetText()
}
return string(query[start.GetStart() : stop.GetStop()+1])
}

View File

@@ -0,0 +1,222 @@
package querybuilder
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSplitFilterForAggregates(t *testing.T) {
agg := map[string]struct{}{"completion_tokens": {}, "span_count": {}, "prompt_tokens": {}}
type tc struct {
name string
query string
span string // expected span-level (WHERE) part; "" => empty
having string // expected trace-level (HAVING) part; "" => empty
wantErr bool
}
cases := []tc{
// --- empty input ---------------------------------------------------------
{
name: "empty",
},
{
name: "whitespace only",
query: " ",
},
// --- single class --------------------------------------------------------
{
name: "span only",
query: "service.name = 'x'",
span: "service.name = 'x'",
},
{
name: "agg only bare",
query: "completion_tokens > 1000",
having: "completion_tokens > 1000",
},
{
// the user-facing `trace.` prefix marks a trace-level aggregate.
name: "agg only trace prefix",
query: "trace.completion_tokens > 1000",
having: "trace.completion_tokens > 1000",
},
{
// an unknown name under the trace context still routes trace-level, so the
// aggregate validation rejects it with a targeted error instead of the span
// path failing on an unknown field.
name: "unknown aggregate under trace context stays trace-level",
query: "trace.not_an_aggregate > 1000",
having: "trace.not_an_aggregate > 1000",
},
{
// ANTLR token offsets are rune indices; slicing must not shift after a
// multi-byte char (this used to truncate 1000 → 100).
name: "unicode value before the split",
query: "service.name = 'héllo' AND completion_tokens > 1000",
span: "service.name = 'héllo'",
having: "completion_tokens > 1000",
},
// --- top-level AND splits across the two buckets -------------------------
{
name: "span AND agg",
query: "service.name = 'x' AND completion_tokens > 1000",
span: "service.name = 'x'",
having: "completion_tokens > 1000",
},
{
// order within a bucket is preserved; the two span atoms join with AND.
name: "span AND span AND agg",
query: "service.name = 'x' AND kind_string = 'Internal' AND completion_tokens > 1000",
span: "service.name = 'x' AND kind_string = 'Internal'",
having: "completion_tokens > 1000",
},
{
// a parenthesized top-level AND still splits across the two buckets.
name: "parenthesized span AND agg",
query: "(service.name = 'x' AND completion_tokens > 1000)",
span: "service.name = 'x'",
having: "completion_tokens > 1000",
},
// --- OR groups are re-wrapped in parens so a later AND-join can't invert
// precedence (`a AND (b OR c)` must not flatten to `a AND b OR c`) ------
{
name: "agg OR agg",
query: "completion_tokens > 1000 OR span_count > 3",
having: "(completion_tokens > 1000 OR span_count > 3)",
},
{
name: "span OR span",
query: "service.name = 'x' OR kind_string = 'Internal'",
span: "(service.name = 'x' OR kind_string = 'Internal')",
},
{
name: "span AND (span OR span)",
query: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
span: "service.name = 'x' AND (kind_string = 'Internal' OR kind_string = 'Client')",
},
{
name: "agg AND (agg OR agg)",
query: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
having: "prompt_tokens > 5 AND (completion_tokens > 1000 OR span_count > 3)",
},
{
// the OR group routes to span, the trailing aggregate to having.
name: "span AND (span OR span) AND agg",
query: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z') AND completion_tokens > 1000",
span: "a.b = 'x' AND (c.d = 'y' OR e.f = 'z')",
having: "completion_tokens > 1000",
},
// --- a nested AND group flattens across the buckets (no spurious parens) --
{
name: "(span AND agg) AND agg",
query: "(service.name = 'x' AND completion_tokens > 1000) AND prompt_tokens > 5",
span: "service.name = 'x'",
having: "completion_tokens > 1000 AND prompt_tokens > 5",
},
// --- NOT wrapping a single-class group is routed whole to that class ------
{
name: "not agg",
query: "NOT (completion_tokens > 1000)",
having: "NOT (completion_tokens > 1000)",
},
{
name: "not span",
query: "NOT (service.name = 'x')",
span: "NOT (service.name = 'x')",
},
// --- an explicit non-trace context escapes the aggregate-alias shadow -----
{
// a span attribute named like an aggregate stays reachable via `attribute.`.
name: "attribute prefix on aggregate name routes span-level",
query: "attribute.completion_tokens > 5",
span: "attribute.completion_tokens > 5",
},
{
name: "span prefix on aggregate name routes span-level",
query: "span.completion_tokens > 5",
span: "span.completion_tokens > 5",
},
{
name: "prefixed attribute AND bare aggregate split across buckets",
query: "attribute.completion_tokens > 5 AND completion_tokens > 1000",
span: "attribute.completion_tokens > 5",
having: "completion_tokens > 1000",
},
// --- class-mixing is rejected in an OR group, a NOT group, or a nested OR -
{
name: "agg OR span rejected",
query: "completion_tokens > 1000 OR service.name = 'x'",
wantErr: true,
},
{
name: "not mixed rejected",
query: "NOT (completion_tokens > 1000 AND service.name = 'x')",
wantErr: true,
},
{
name: "span AND (agg OR span) rejected",
query: "service.name = 'x' AND (completion_tokens > 1000 OR kind_string = 'Client')",
wantErr: true,
},
// --- syntax errors are rejected, not silently dropped by error recovery ---
{
// recovery would yield an empty tree → both buckets empty → filter ignored.
name: "lone paren rejected",
query: ")",
wantErr: true,
},
{
name: "unbalanced parens rejected",
query: "((",
wantErr: true,
},
{
name: "bare operator rejected",
query: "AND",
wantErr: true,
},
{
// lexer-level error: recovery drops the whole expression.
name: "unterminated quote rejected",
query: "'unterminated",
wantErr: true,
},
{
// recovery would drop only the malformed atom and keep the rest — a
// partially applied filter with no error.
name: "garbage atom alongside valid agg rejected",
query: ") AND completion_tokens > 5",
wantErr: true,
},
{
name: "missing value rejected",
query: "completion_tokens >",
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
span, having, err := SplitFilterForAggregates(c.query, agg)
if c.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, c.span, span, "span part")
require.Equal(t, c.having, having, "having part")
})
}
}

View File

@@ -18,6 +18,19 @@ func NewHavingExpressionRewriter() *HavingExpressionRewriter {
}
}
// Rewrite rewrites and validates a HAVING expression against a caller-supplied
// column map (user-facing name -> SQL identifier/expression). Values are inlined, so
// the result is a bare SQL boolean expression with no bound args. Used by callers
// that project their own aggregate columns (e.g. the AI trace list) rather than the
// query's Aggregations.
func (r *HavingExpressionRewriter) Rewrite(expression string, columnMap map[string]string) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {
return "", nil
}
r.columnMap = columnMap
return r.rewriteAndValidate(expression)
}
// RewriteForTraces rewrites and validates the HAVING expression for a traces query.
func (r *HavingExpressionRewriter) RewriteForTraces(expression string, aggregations []qbtypes.TraceAggregation) (string, error) {
if len(strings.TrimSpace(expression)) == 0 {

View File

@@ -82,6 +82,10 @@ func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.Variable
switch queryType {
case qbtypes.QueryTypeBuilder.StringValue(), qbtypes.QueryTypeSubQuery.StringValue():
return resourcesForBuilderQuery(queryType, query.Get("spec"), variables)
case qbtypes.QueryTypeBuilderAI.StringValue():
// An AI builder query is always a traces query; the signal is implied by the
// type (and may be absent from the payload), so pin the resource directly.
return builderQueryResourceRefs(queryType, coretypes.ResourceTelemetryResourceTraces, query.Get("spec"), variables)
case qbtypes.QueryTypePromQL.StringValue():
return []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceMetrics, ID: typeWildcard}}, nil
case qbtypes.QueryTypeClickHouseSQL.StringValue():
@@ -103,7 +107,10 @@ func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map
if err != nil {
return nil, err
}
return builderQueryResourceRefs(queryType, resource, spec, variables)
}
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
if err != nil {
return nil, err

View File

@@ -92,6 +92,20 @@ func TestQueryRangeResources(t *testing.T) {
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
},
},
{
name: "ai builder query maps to traces resource without a signal",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/signoz.workspace.key.id/checkout"},
},
},
{
name: "ai builder query without filter is wildcard",
body: `{"compositeQuery":{"queries":[{"type":"builder_ai_query","spec":{}}]}}`,
expected: []coretypes.ResourceWithID{
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_ai_query/*"},
},
},
{
name: "promql is wildcard only",
body: `{"compositeQuery":{"queries":[{"type":"promql","spec":{"query":"up"}}]}}`,

View File

@@ -289,9 +289,9 @@ func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgG
)
}
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], aiTraceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
return factory.MustNewNamedMap(
signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
)
}

View File

@@ -48,6 +48,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlmigrator"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/auditstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/meterstatementbuilder"
@@ -99,9 +100,9 @@ type SigNoz struct {
// newQueryStack assembles the query stack once and returns, in order: the shared
// telemetry metadata store (reused elsewhere in signoz.New), the per-signal
// statement builders (trace, log, audit, metric, meter, trace-operator), and the
// bucket cache. It is the only place that imports the concrete statement-builder
// sub-packages.
// statement builders (trace, ai-trace, log, audit, metric, meter, trace-operator),
// and the bucket cache. It is the only place that imports the concrete
// statement-builder sub-packages.
func newQueryStack(
ctx context.Context,
settings factory.ProviderSettings,
@@ -112,6 +113,7 @@ func newQueryStack(
) (
telemetrytypes.MetadataStore,
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
qbtypes.StatementBuilder[qbtypes.TraceAggregation],
qbtypes.StatementBuilder[qbtypes.LogAggregation],
qbtypes.StatementBuilder[qbtypes.LogAggregation],
qbtypes.StatementBuilder[qbtypes.MetricAggregation],
@@ -125,32 +127,36 @@ func newQueryStack(
cfg := config.StatementBuilder
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
traceOperatorStmtBuilder, err := tracesstatementbuilder.NewOperatorFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
logStmtBuilder, err := logsstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
auditStmtBuilder, err := auditstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
metricStmtBuilder, err := metricsstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, nil, nil, nil, nil, nil, nil, nil, err
return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
}
bucketCache := querier.NewBucketCache(settings, cache, config.Querier.CacheTTL, config.Querier.FluxInterval)
return metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
return metadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, nil
}
func New(
@@ -313,7 +319,7 @@ func New(
// Assemble the query stack (metadata store, statement builders, bucket cache) once,
// and reuse the single metadata store everywhere downstream.
telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
if err != nil {
return nil, err
}
@@ -323,7 +329,7 @@ func New(
ctx,
providerSettings,
config.Querier,
NewQuerierProviderFactories(telemetrystore, prometheus, telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
NewQuerierProviderFactories(telemetrystore, prometheus, telemetryMetadataStore, traceStmtBuilder, aiTraceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
config.Querier.Provider(),
)
if err != nil {

View File

@@ -0,0 +1,84 @@
package aistatementbuilder
import (
"strings"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/statementbuilder"
scopedtraces "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder"
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// NewFactory returns a provider factory for the AI trace statement builder
// (builder_ai_query): the gen_ai Scope paired with the domain-neutral scoped-trace
// topology, which owns the query construction.
//
// The gen_ai gate/column keys are surfaced by the metadata store itself
// (enrichWithGenAIKeys), so queries work before any gen_ai metadata is ingested.
func NewFactory(
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
fl flagger.Flagger,
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
return scopedtraces.NewFactory(factory.MustNewName("ai"), Scope(), telemetryStore, metadataStore, fl)
}
// Scope describes gen_ai for the scoped trace builder: an AI trace has >=1 gen_ai
// LLM, tool, or agent span, and its list adds AI/LLM per-trace metrics on top of the
// common columns. This package holds only gen_ai domain knowledge; the query
// topology lives in scopedtracesstatementbuilder.
func Scope() scopedtraces.TraceScope {
gateKeyNames := []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName}
gateExprs := make([]string, 0, len(gateKeyNames))
gateKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(gateKeyNames))
for _, name := range gateKeyNames {
gateExprs = append(gateExprs, name+" EXISTS")
gateKeys = append(gateKeys, &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
})
}
defs := telemetrytypes.GenAIFieldDefinitions
reqModel := defs[telemetrytypes.GenAIRequestModel]
toolName := defs[telemetrytypes.GenAIToolName]
inTok := defs[telemetrytypes.GenAIUsageInputTokens]
outTok := defs[telemetrytypes.GenAIUsageOutputTokens]
cost := defs[telemetrytypes.SignozGenAITotalCost]
inMsg := defs[telemetrytypes.GenAIInputMessages]
outMsg := defs[telemetrytypes.GenAIOutputMessages]
str := telemetrytypes.FieldDataTypeString
columns := append(scopedtraces.CommonTraceColumns(),
// LLM calls only (request model present), not the full gate.
scopedtraces.TraceColumn{Alias: "llm_call_count", Orderable: true, Expr: scopedtraces.CountExists(&reqModel)},
scopedtraces.TraceColumn{Alias: "tool_call_count", Orderable: true, Expr: scopedtraces.CountExists(&toolName)},
scopedtraces.TraceColumn{Alias: "distinct_tool_count", Orderable: true, Expr: scopedtraces.UniqCount(&toolName, str)},
// tokens live only on LLM spans, so a plain sum needs no gate scoping.
scopedtraces.TraceColumn{Alias: "input_tokens", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &inTok)},
scopedtraces.TraceColumn{Alias: "output_tokens", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &outTok)},
scopedtraces.TraceColumn{Alias: "total_tokens", Orderable: true, Expr: scopedtraces.SumOfKeys(telemetrytypes.FieldDataTypeFloat64, &inTok, &outTok)},
// per-span cost attached by the SigNoz LLM pricing processor.
scopedtraces.TraceColumn{Alias: "estimated_total_cost", Orderable: true, Expr: scopedtraces.Reduce(scopedtraces.AggSum, &cost)},
// slowest single LLM call in the trace.
scopedtraces.TraceColumn{Alias: "max_llm_duration_nano", Orderable: true, Expr: scopedtraces.ScopedToKeyColumn(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("duration_nano"), &reqModel)},
// errors across the whole trace (any span), so display-only.
scopedtraces.TraceColumn{Alias: "error_count", Expr: scopedtraces.CondCount(scopedtraces.IntrinsicSpanKey("has_error"), qbtypes.FilterOperatorEqual, true)},
// timestamp of the last gen_ai span (LLM/tool/agent), hence gate-scoped.
scopedtraces.TraceColumn{Alias: "last_activity_time", Orderable: true, Expr: scopedtraces.ScopedReduce(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("timestamp"))},
// previews: first call's input (the prompt), last call's output (the answer).
scopedtraces.TraceColumn{Alias: "input", SpanLevel: true, Expr: scopedtraces.PickBy(&inMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickEarliest)},
scopedtraces.TraceColumn{Alias: "output", SpanLevel: true, Expr: scopedtraces.PickBy(&outMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickLatest)},
)
return scopedtraces.TraceScope{
FilterExpression: strings.Join(gateExprs, " OR "),
FieldKeys: gateKeys,
Columns: columns,
DefaultOrderAlias: "last_activity_time",
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,208 @@
package scopedtracesstatementbuilder
import (
"context"
"fmt"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// This file holds the Aggregate constructors a TraceScope's columns are declared
// with. All SQL rendering goes through the fieldMapper.
// Aggregate renders one column's SQL through the fieldMapper and lists the attribute
// keys it references so the builder can pre-fetch their metadata. Build one with the
// constructors below; the zero value is not usable.
type Aggregate struct {
keys []*telemetrytypes.TelemetryFieldKey
render func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (expr string, args []any, err error)
}
// IntrinsicSpanKey references an intrinsic span-index field (timestamp, name, …) by
// its canonical name; the field mapper resolves it to the physical column.
func IntrinsicSpanKey(name string) *telemetrytypes.TelemetryFieldKey {
return &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
}
}
// AggFunc is a ClickHouse aggregate function name.
type AggFunc string
const (
AggSum AggFunc = "sum"
AggMax AggFunc = "max"
AggMin AggFunc = "min"
)
// PickDirection selects the earliest (argMin) or latest (argMax) span by ordering.
type PickDirection int
const (
PickLatest PickDirection = iota
PickEarliest
)
// CountAll renders count().
func CountAll() Aggregate {
return Aggregate{render: func(context.Context, valuer.UUID, uint64, uint64, *fieldMapper) (string, []any, error) {
return "count()", nil, nil
}}
}
// FieldReduce renders <fn>(<field>) over a field-mapper-resolved column.
func FieldReduce(fn AggFunc, key *telemetrytypes.TelemetryFieldKey) Aggregate {
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
f, err := m.FieldFor(ctx, orgID, startNs, endNs, key)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("%s(%s)", fn, f), nil, nil
}}
}
// TraceDuration renders the full-trace wall duration: last span end minus first
// span start.
func TraceDuration(tsKey, durationKey *telemetrytypes.TelemetryFieldKey) Aggregate {
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
ts, err := m.FieldFor(ctx, orgID, startNs, endNs, tsKey)
if err != nil {
return "", nil, err
}
dur, err := m.FieldFor(ctx, orgID, startNs, endNs, durationKey)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("(max(toUnixTimestamp64Nano(%s) + %s) - min(toUnixTimestamp64Nano(%s)))", ts, dur, ts), nil, nil
}}
}
// FieldAnyWhere renders anyIf(<field>, <cond>) — the field value from any span
// matching the condition.
func FieldAnyWhere(valueKey, condKey *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, condValue any) Aggregate {
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
v, err := m.FieldFor(ctx, orgID, startNs, endNs, valueKey)
if err != nil {
return "", nil, err
}
cond, args, err := m.ConditionFor(ctx, orgID, startNs, endNs, condKey, op, condValue)
return fmt.Sprintf("anyIf(%s, %s)", v, cond), args, err
}}
}
// AnyValue renders any(<value>) over a metadata-resolved attribute value.
func AnyValue(key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) Aggregate {
return Aggregate{keys: keysOf(key), render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
v, args, err := m.ValueFor(ctx, orgID, startNs, endNs, key, dt)
return fmt.Sprintf("any(%s)", v), args, err
}}
}
// CountExists renders countIf(<key> EXISTS) — counts spans carrying key.
func CountExists(key *telemetrytypes.TelemetryFieldKey) Aggregate {
return Aggregate{keys: keysOf(key), render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
cond, args, err := m.ExistsFor(ctx, orgID, startNs, endNs, key)
return fmt.Sprintf("countIf(%s)", cond), args, err
}}
}
// CondCount renders countIf(<cond>) over a condition-builder-resolved predicate.
func CondCount(key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) Aggregate {
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
cond, args, err := m.ConditionFor(ctx, orgID, startNs, endNs, key, op, value)
return fmt.Sprintf("countIf(%s)", cond), args, err
}}
}
// Reduce renders <fn>(<value>) over a resolved numeric attribute value.
func Reduce(fn AggFunc, valueKey *telemetrytypes.TelemetryFieldKey) Aggregate {
return Aggregate{keys: keysOf(valueKey), render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
v, args, err := m.ValueFor(ctx, orgID, startNs, endNs, valueKey, telemetrytypes.FieldDataTypeFloat64)
return fmt.Sprintf("%s(%s)", fn, v), args, err
}}
}
// ScopedReduce renders <fn>If(<field>, <gate mask>) over a field-mapper-resolved column.
func ScopedReduce(fn AggFunc, key *telemetrytypes.TelemetryFieldKey) Aggregate {
return Aggregate{render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
f, err := m.FieldFor(ctx, orgID, startNs, endNs, key)
if err != nil {
return "", nil, err
}
return fmt.Sprintf("%sIf(%s, %s)", fn, f, m.maskExpr), append([]any{}, m.maskArgs...), nil
}}
}
// ScopedToKeyColumn renders <fn>If(<field>, <scopeKey> EXISTS) — a span-index field
// aggregated over spans carrying scopeKey (e.g. max LLM latency).
func ScopedToKeyColumn(fn AggFunc, columnKey, scopeKey *telemetrytypes.TelemetryFieldKey) Aggregate {
return Aggregate{keys: keysOf(scopeKey), render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
col, err := m.FieldFor(ctx, orgID, startNs, endNs, columnKey)
if err != nil {
return "", nil, err
}
cond, args, err := m.ExistsFor(ctx, orgID, startNs, endNs, scopeKey)
return fmt.Sprintf("%sIf(%s, %s)", fn, col, cond), args, err
}}
}
// PickBy renders argMinIf/argMaxIf(<value>, <orderField>, <value> EXISTS) — the value
// from the earliest/latest span that carries it.
func PickBy(valueKey *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType, orderKey *telemetrytypes.TelemetryFieldKey, dir PickDirection) Aggregate {
fn := "argMaxIf"
if dir == PickEarliest {
fn = "argMinIf"
}
return Aggregate{keys: keysOf(valueKey), render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
v, vargs, err := m.ValueFor(ctx, orgID, startNs, endNs, valueKey, dt)
if err != nil {
return "", nil, err
}
order, err := m.FieldFor(ctx, orgID, startNs, endNs, orderKey)
if err != nil {
return "", nil, err
}
cond, cargs, err := m.ExistsFor(ctx, orgID, startNs, endNs, valueKey)
return fmt.Sprintf("%s(%s, %s, %s)", fn, v, order, cond), append(vargs, cargs...), err
}}
}
// UniqCount renders uniqIf(<value>, <value> EXISTS) — distinct count of an attribute.
func UniqCount(valueKey *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) Aggregate {
return Aggregate{keys: keysOf(valueKey), render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
v, vargs, err := m.ValueFor(ctx, orgID, startNs, endNs, valueKey, dt)
if err != nil {
return "", nil, err
}
cond, cargs, err := m.ExistsFor(ctx, orgID, startNs, endNs, valueKey)
return fmt.Sprintf("uniqIf(%s, %s)", v, cond), append(vargs, cargs...), err
}}
}
// SumOfKeys renders coalesce(sum(<v1>), 0) + coalesce(sum(<v2>), 0) + … over several
// numeric attributes. Coalesced because a key absent from every span sums to NULL and
// NULL + n = NULL — a trace with only output tokens would otherwise total NULL.
func SumOfKeys(dt telemetrytypes.FieldDataType, valueKeys ...*telemetrytypes.TelemetryFieldKey) Aggregate {
return Aggregate{keys: valueKeys, render: func(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, m *fieldMapper) (string, []any, error) {
parts := make([]string, 0, len(valueKeys))
var args []any
for _, k := range valueKeys {
v, vargs, err := m.ValueFor(ctx, orgID, startNs, endNs, k, dt)
if err != nil {
return "", nil, err
}
parts = append(parts, fmt.Sprintf("coalesce(sum(%s), 0)", v))
args = append(args, vargs...)
}
return strings.Join(parts, " + "), args, nil
}}
}
func keysOf(k *telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
return []*telemetrytypes.TelemetryFieldKey{k}
}

View File

@@ -0,0 +1,82 @@
package scopedtracesstatementbuilder
import (
"context"
"strings"
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"
)
// fieldMapper resolves aggregate-column SQL through the shared field mapper and
// condition builder, following their method shapes (FieldFor / ConditionFor / …) so
// column resolution reads like the other statement builders. keys is the fetched
// metadata for the keys the columns reference; the gate mask is set by the builder
// after resolveMask (Scoped* aggregates embed it). All returned expressions are
// escaped once, ready to embed in an outer builder.
type fieldMapper struct {
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
keys map[string][]*telemetrytypes.TelemetryFieldKey
maskExpr string
maskArgs []any
}
func newFieldMapper(fm qbtypes.FieldMapper, cb qbtypes.ConditionBuilder, keys map[string][]*telemetrytypes.TelemetryFieldKey) *fieldMapper {
return &fieldMapper{fm: fm, cb: cb, keys: keys}
}
// FieldFor returns the column expression for key via the field mapper.
func (r *fieldMapper) FieldFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
return r.fm.FieldFor(ctx, orgID, startNs, endNs, key)
}
// ConditionFor returns a boolean predicate for key via the condition builder
// (materialized column when present, else map access).
func (r *fieldMapper) ConditionFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) (string, []any, error) {
sb := sqlbuilder.NewSelectBuilder()
// The condition builder owns key resolution: hand it the raw key plus the full
// metadata map and it matches/synthesizes the candidates itself.
conds, _, err := r.cb.ConditionFor(ctx, orgID, startNs, endNs, key, r.keys, qbtypes.ConditionBuilderOptions{}, op, value, sb)
if err != nil {
return "", nil, err
}
if len(conds) == 0 {
return "", nil, nil
}
// One condition per candidate variant (a key can be ingested under several data
// types); OR them all, like the visitor does for EXISTS.
if len(conds) == 1 {
sb.Where(conds[0])
} else {
sb.Where(sb.Or(conds...))
}
expr, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
expr = strings.TrimPrefix(expr, "WHERE ")
return sqlbuilder.Escape(expr), args, nil
}
// ExistsFor returns the EXISTS predicate for key.
func (r *fieldMapper) ExistsFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey) (string, []any, error) {
return r.ConditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorExists, nil)
}
// ValueFor returns the value expression for an attribute key.
func (r *fieldMapper) ValueFor(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, key *telemetrytypes.TelemetryFieldKey, dt telemetrytypes.FieldDataType) (string, []any, error) {
// The scope declares keys statically, so a key like service.name can have evolutions.
// this is added for the evolution.
if cands := r.keys[key.Name]; len(cands) > 0 {
key = cands[0]
}
expr, err := r.fm.ColumnExpressionFor(ctx, orgID, startNs, endNs, key, dt, r.keys)
if err != nil {
return "", nil, err
}
// Escape before embedding in the outer builder: a materialized column name carries
// `$$` (from the dotted attribute name), which go-sqlbuilder's Build would otherwise
// unescape to a single `$` and reference the wrong column.
return sqlbuilder.Escape(expr), nil, nil
}

View File

@@ -0,0 +1,66 @@
package scopedtracesstatementbuilder
import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// TraceScope is the configuration the scoped trace builder accepts: which spans are
// in scope and which per-trace columns the list computes. It only declares the gate
// and the columns; the builder resolves everything through the field mapper, so
// attribute access stays materialization-aware. A new span category only needs a
// new TraceScope.
type TraceScope struct {
// FilterExpression is the grammar-level (EXISTS) gate, used on the delegated
// span-list path.
FilterExpression string
// FieldKeys are the gate's keys, used to build the per-span mask
// (OR of resolved EXISTS conditions).
FieldKeys []*telemetrytypes.TelemetryFieldKey
// Columns are the per-trace output columns.
Columns []TraceColumn
// DefaultOrderAlias is sorted by (desc) when the query gives no order.
DefaultOrderAlias string
}
// TraceColumn is one per-trace output column.
type TraceColumn struct {
// Alias must not reuse a physical span-index column name (e.g. duration_nano):
// ClickHouse resolves bare identifiers to same-SELECT aliases first, so any
// expression referencing that column would silently bind to the alias.
Alias string
// Orderable columns can be used in ORDER BY and the aggregate filter. All-span
// aggregates (span_count, trace_duration_nano, …) are display-only and set false.
Orderable bool
// SpanLevel columns surface a real span/resource attribute (service.name,
// input/output messages); a filter on them is applied span-level, so they are
// excluded from the trace-level aliases.
SpanLevel bool
Expr Aggregate
}
// CommonTraceColumns are domain-neutral columns any trace list can reuse. All
// aggregate over every span, so none is Orderable.
func CommonTraceColumns() []TraceColumn {
ts := IntrinsicSpanKey("timestamp")
duration := IntrinsicSpanKey("duration_nano")
name := IntrinsicSpanKey("name")
parentSpanID := IntrinsicSpanKey("parent_span_id")
serviceName := &telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
return []TraceColumn{
{Alias: "start_time", Expr: FieldReduce(AggMin, ts)},
{Alias: "end_time", Expr: FieldReduce(AggMax, ts)},
// Not plain "duration_nano": that name is the intrinsic span field, and an
// alias would shadow it — both in ClickHouse identifier resolution and in
// bare-name filter classification.
{Alias: "trace_duration_nano", Expr: TraceDuration(ts, duration)},
{Alias: "span_count", Expr: CountAll()},
{Alias: "root_span_name", Expr: FieldAnyWhere(name, parentSpanID, qbtypes.FilterOperatorEqual, "")},
{Alias: "service.name", SpanLevel: true, Expr: AnyValue(serviceName, telemetrytypes.FieldDataTypeString)},
}
}

View File

@@ -0,0 +1,802 @@
package scopedtracesstatementbuilder
import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/resourcefilter"
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
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"
)
var (
ErrUnsupportedRequestType = errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type for the scoped trace builder")
)
// scopedTraceStatementBuilder builds a trace list scoped to one span category
// (e.g. gen_ai spans). The query shape is fixed; the TraceScope decides which spans
// are in scope and which per-trace columns to compute, so a new category only needs
// a new scope.
type scopedTraceStatementBuilder struct {
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
scope TraceScope
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
}
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*scopedTraceStatementBuilder)(nil)
// NewFactory returns a provider factory for a scoped trace statement builder. Unlike
// the per-signal factories this package is domain-neutral, so the caller supplies the
// factory name and the TraceScope (see aistatementbuilder for the gen_ai scope). Its
// New delegates the span-list path to a trace statement builder built via the traces
// factory — mirroring how the meter factory builds its own metrics builder.
func NewFactory(
name factory.Name,
scope TraceScope,
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
fl flagger.Flagger,
) factory.ProviderFactory[qbtypes.StatementBuilder[qbtypes.TraceAggregation], statementbuilder.Config] {
return factory.NewProviderFactory(
name,
func(ctx context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
traceStmtBuilder, err := tracesstatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(ctx, settings, cfg)
if err != nil {
return nil, err
}
return NewScopedTraceStatementBuilder(settings, metadataStore, scope, traceStmtBuilder, fl), nil
},
)
}
// NewScopedTraceStatementBuilder wires the generic trace-list builder. The field
// mapper / condition builder are built here, not injected — the list always scans the
// traces span index. traceStmtBuilder (the delegate for the span-list path) is
// injected because NewFactory already builds the canonical instance.
func NewScopedTraceStatementBuilder(
settings factory.ProviderSettings,
metadataStore telemetrytypes.MetadataStore,
scope TraceScope,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
fl flagger.Flagger,
) qbtypes.StatementBuilder[qbtypes.TraceAggregation] {
scopedSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder")
fm := tracestelemetryschema.NewFieldMapper()
cb := tracestelemetryschema.NewConditionBuilder(fm)
// Same resource-fingerprint prune as the standard trace builder — the list scans
// the same span index.
resourceFilterStmtBuilder := resourcefilter.New[qbtypes.TraceAggregation](
settings,
tracestelemetryschema.DBName,
tracestelemetryschema.TracesResourceV3TableName,
telemetrytypes.SignalTraces,
telemetrytypes.SourceUnspecified,
metadataStore,
nil,
fl,
)
return &scopedTraceStatementBuilder{
logger: scopedSettings.Logger(),
metadataStore: metadataStore,
fm: fm,
cb: cb,
scope: scope,
traceStmtBuilder: traceStmtBuilder,
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
}
}
func (b *scopedTraceStatementBuilder) Build(
ctx context.Context,
orgID valuer.UUID,
start uint64,
end uint64,
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
switch requestType {
case qbtypes.RequestTypeTrace:
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)
default:
return nil, ErrUnsupportedRequestType
}
}
// buildDelegated ANDs the base gate into the user filter and delegates to the
// standard trace builder (the span-list / raw path).
func (b *scopedTraceStatementBuilder) buildDelegated(
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) {
gate := b.scope.FilterExpression
expr := gate
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
expr = fmt.Sprintf("(%s) AND (%s)", gate, query.Filter.Expression)
}
// shallow copy; only Filter is replaced, caller's query untouched
gated := query
gated.Filter = &qbtypes.Filter{Expression: expr}
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
}
// buildTraceListQuery wires the CTE pipeline: one windowed pass picks the top-N
// traces, then a bucket-pruned pass enriches only those.
// Helpers appear in this file in the order they run. start/end are nanoseconds.
//
// RESOLVE (keys/columns → SQL via the field mapper)
// fetchKeys metadata for every key we reference
// resolveMask the "span is in scope" predicate (OR of EXISTS)
// resolveColumns per-trace column SQL
// resolveListOrders which columns to ORDER BY
// splitFilter span-level predicate + trace-level HAVING
//
// BUILD
// matched one windowed, mask-pruned GROUP BY trace_id scan fusing gate + span
// │ filter + HAVING + ORDER BY + LIMIT/OFFSET → the top-N trace_ids
// ▼
// ranked [start,end] bounds of those traces, from the small summary table
// ▼
// buckets the ts_bucket_start values they touch, to prune the next scan
// ▼
// enrichment every per-trace column for those traces over their full extent
// (not window-clipped), scanning only their buckets
//
// Only Orderable columns are computable in the mask-pruned matched pass, so only they
// can be ordered or filtered on; all-span columns (span_count, …) are output-only.
func (b *scopedTraceStatementBuilder) buildTraceListQuery(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
endBucket := end / querybuilder.NsToSeconds
limit := query.Limit
if limit <= 0 {
limit = 100
}
// Resolve keys and columns once; all attribute access goes through the field mapper.
keys, err := b.fetchKeys(ctx, orgID)
if err != nil {
return nil, err
}
mapper := newFieldMapper(b.fm, b.cb, keys)
maskExpr, maskArgs, err := b.resolveMask(ctx, orgID, start, end, mapper)
if err != nil {
return nil, err
}
mapper.maskExpr, mapper.maskArgs = maskExpr, maskArgs
resolved, err := b.resolveColumns(ctx, orgID, start, end, mapper)
if err != nil {
return nil, err
}
orders, err := b.resolveListOrders(query.Order, resolved)
if err != nil {
return nil, err
}
orderableSet := orderableAliasSet(resolved)
// If the filter references resource attributes, add a __resource_filter CTE and
// narrow the matched scan by resource_fingerprint; the span predicate drops those
// keys so they aren't applied twice.
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
if err != nil {
return nil, err
}
// Split the user filter: span-level predicate + trace-level HAVING expression.
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), orderableSet, start, end, variables)
if err != nil {
return nil, err
}
// matched → ranked → buckets → enrichment
matchedFrag, matchedArgs, err := b.buildMatchedCTE(start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, maskArgs, fp, resourcePred, limit, query.Offset)
if err != nil {
return nil, err
}
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
bucketsFrag := buildBucketsCTE()
mainSQL, mainArgs := b.buildEnrichmentSelect(resolved, orders)
cteFragments := []string{matchedFrag, rankedFrag, bucketsFrag}
cteArgs := [][]any{matchedArgs, rankedArgs, nil}
// __resource_filter must precede `matched`, which references it.
if resourceFrag != "" {
cteFragments = append([]string{resourceFrag}, cteFragments...)
cteArgs = append([][]any{resourceArgs}, cteArgs...)
}
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: fp.warnings,
WarningsDocURL: fp.warningsURL,
}, nil
}
// maybeAttachResourceFilter builds the __resource_filter CTE (fingerprints matching
// the filter's resource conditions) and the predicate narrowing the span scan by
// resource_fingerprint; with no resource conditions it returns empty fragments.
//
// Unlike the standard trace builder there is deliberately no skip-fingerprint
// fallback: falling back would leave the resource conditions inside the OR'd
// span-filter bucket, which changes trace membership (any span from the resource +
// any gen_ai span, instead of a gen_ai span from the resource). Resource conditions
// always scope the whole matched scan.
func (b *scopedTraceStatementBuilder) maybeAttachResourceFilter(
ctx context.Context,
orgID valuer.UUID,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (cteFrag string, cteArgs []any, fingerprintPred string, err error) {
stmt, err := b.resourceFilterStmtBuilder.Build(
ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables,
)
if err != nil {
return "", nil, "", err
}
if stmt == nil {
return "", nil, "", nil
}
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args,
"resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)", nil
}
// ---------------------------------------------------------------------------
// RESOLVE — turn keys/columns into field-mapper-aware SQL
// ---------------------------------------------------------------------------
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
fields := b.resolverFieldKeys()
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
for _, k := range fields {
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
Name: k.Name,
Signal: k.Signal,
FieldContext: k.FieldContext,
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
})
}
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
return keys, err
}
func (b *scopedTraceStatementBuilder) resolverFieldKeys() []*telemetrytypes.TelemetryFieldKey {
seen := make(map[string]struct{})
var out []*telemetrytypes.TelemetryFieldKey
add := func(k *telemetrytypes.TelemetryFieldKey) {
if k == nil {
return
}
if _, dup := seen[k.Name]; dup {
return
}
seen[k.Name] = struct{}{}
out = append(out, k)
}
for _, k := range b.scope.FieldKeys {
add(k)
}
for _, c := range b.scope.Columns {
for _, k := range c.Expr.keys {
add(k)
}
}
return out
}
// resolveMask builds the per-span in-scope mask: OR of resolved EXISTS predicates
// over the base condition's field keys.
func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID valuer.UUID, start, end uint64, mapper *fieldMapper) (string, []any, error) {
fieldKeys := b.scope.FieldKeys
parts := make([]string, 0, len(fieldKeys))
var args []any
for _, key := range fieldKeys {
e, a, err := mapper.ExistsFor(ctx, orgID, start, end, key)
if err != nil {
return "", nil, err
}
parts = append(parts, e)
args = append(args, a...)
}
return "(" + strings.Join(parts, " OR ") + ")", args, nil
}
// resolvedColumn is a column resolved to SQL via the field mapper; expr is escaped
// once, ready to embed in an outer SELECT.
type resolvedColumn struct {
alias string
expr string
args []any
orderable bool
}
// resolveColumns turns the declarative columns into SQL through the resolver, so all
// attribute access goes through the field mapper / condition builder.
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, mapper *fieldMapper) ([]resolvedColumn, error) {
cols := b.scope.Columns
out := make([]resolvedColumn, 0, len(cols))
for _, c := range cols {
expr, args, err := c.Expr.render(ctx, orgID, start, end, mapper)
if err != nil {
return nil, err
}
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, args: args, orderable: c.Orderable})
}
return out, nil
}
// listOrder is a sort key resolved to a column alias + direction; both the matched
// CTE and the enrichment ORDER BY it.
type listOrder struct {
alias string
direction string
}
// resolveListOrders maps order keys to the resolved orderable columns; non-orderable
// columns are rejected. Defaults to the column provider's default order.
func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy, resolved []resolvedColumn) ([]listOrder, error) {
byAlias := make(map[string]resolvedColumn, len(resolved))
orderable := make([]string, 0, len(resolved))
for _, rc := range resolved {
byAlias[rc.alias] = rc
if rc.orderable {
orderable = append(orderable, rc.alias)
}
}
if len(order) == 0 {
return []listOrder{{alias: b.scope.DefaultOrderAlias, direction: "DESC"}}, nil
}
orders := make([]listOrder, 0, len(order))
for _, o := range order {
direction := "DESC"
if o.Direction == qbtypes.OrderDirectionAsc {
direction = "ASC"
}
rc, ok := byAlias[o.Key.Name]
if !ok || !rc.orderable {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"unsupported order key %q for the trace list; orderable keys: %s", o.Key.Name, strings.Join(orderable, ", "))
}
orders = append(orders, listOrder{alias: rc.alias, direction: direction})
}
return orders, nil
}
// filterParts is the user filter split into a span-level predicate (widens the
// matched WHERE prune and becomes a countIf existence check in HAVING) and a
// trace-level HAVING expression.
type filterParts struct {
spanPred string
spanArgs []any
hasSpanFilter bool
havingExpr string
warnings []string
warningsURL string
}
// splitFilter splits query.Filter into a span-level predicate and a trace-level
// HAVING expression (an explicit query.Having is ANDed onto the latter), 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) (filterParts, error) {
var fp filterParts
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
if strings.TrimSpace(spanExpr) != "" {
pred, args, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables)
if err != nil {
return fp, err
}
// pred is empty when the span-level keys were all resource attributes
// already handled by the __resource_filter CTE.
if strings.TrimSpace(pred) != "" {
fp.spanPred, fp.spanArgs, fp.hasSpanFilter = pred, args, true
}
fp.warnings, fp.warningsURL = warnings, url
}
}
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
if fp.havingExpr != "" {
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
} else {
fp.havingExpr = query.Having.Expression
}
}
// The span predicate binds variables via PrepareWhereClause; the HAVING is a plain
// text rewrite, so substitute variables here (list/IN quoting, __all__ drops the
// condition) before validating.
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 {
return fp, err
}
return fp, nil
}
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean
// SQL predicate + args via the field mapper.
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, variables map[string]qbtypes.VariableItem) (string, []any, []string, string, error) {
selectors := querybuilder.QueryStringToKeysSelectors(expr)
for i := range selectors {
selectors[i].Signal = telemetrytypes.SignalTraces
}
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, selectors)
if err != nil {
return "", nil, nil, "", err
}
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Logger: b.logger,
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
// resource conditions are always handled by the __resource_filter CTE
SkipResourceFilter: true,
Variables: variables,
StartNs: start,
EndNs: end,
})
if err != nil {
return "", nil, nil, "", err
}
if prepared.IsEmpty() {
return "", nil, nil, "", nil
}
sb := sqlbuilder.NewSelectBuilder()
sb.AddWhereClause(prepared.WhereClause)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
pred := sql[strings.Index(sql, "WHERE ")+len("WHERE "):]
return sqlbuilder.Escape(pred), args, prepared.Warnings, prepared.WarningsDocURL, nil
}
// buildMatchedCTE builds `matched`: the single windowed GROUP BY trace_id scan that
// fuses gate + span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the
// aliases the ORDER BY / HAVING reference.
func (b *scopedTraceStatementBuilder) buildMatchedCTE(start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, maskArgs []any, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
sb := sqlbuilder.NewSelectBuilder()
// SELECT trace_id + only the aggregates ORDER BY / HAVING reference (as aliases).
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
selects := []string{"trace_id"}
for _, rc := range resolved {
if _, ok := needed[rc.alias]; !ok {
continue
}
colExpr, err := embedExpr(sb, rc.expr, rc.args)
if err != nil {
return "", nil, err
}
selects = append(selects, colExpr+" AS "+quoteAlias(rc.alias))
}
sb.Select(selects...)
sb.From(spanTable())
// WHERE: window + prune to in-scope spans, widened by the span filter so its
// spans survive for the countIf existence check below.
win := windowWhere(sb, start, end, startBucket, endBucket)
mask, err := embedExpr(sb, maskExpr, maskArgs)
if err != nil {
return "", nil, err
}
prune := "(" + mask
if fp.hasSpanFilter {
spanPred, err := embedExpr(sb, fp.spanPred, fp.spanArgs)
if err != nil {
return "", nil, err
}
prune += " OR " + spanPred
}
prune += ")"
where := append(win, prune)
if resourcePred != "" {
where = append(where, resourcePred)
}
sb.Where(where...)
sb.GroupBy("trace_id")
// HAVING: the gate/span existence checks are only needed when the WHERE was
// widened by a span filter; otherwise the mask alone already enforces the gate.
var having []string
if fp.hasSpanFilter {
havingMask, err := embedExpr(sb, maskExpr, maskArgs)
if err != nil {
return "", nil, err
}
havingPred, err := embedExpr(sb, fp.spanPred, fp.spanArgs)
if err != nil {
return "", nil, err
}
having = append(having, "countIf("+havingMask+") > 0")
having = append(having, "countIf("+havingPred+") > 0")
}
if strings.TrimSpace(fp.havingExpr) != "" {
hv, err := b.buildHaving(fp.havingExpr, orderableSet)
if err != nil {
return "", nil, err
}
if hv != "" {
// hv carries user text with values inlined by the rewriter; escape it so a
// literal $ can't be read as an interpolation marker at Build time. The
// countIf entries above hold live placeholders and must stay unescaped.
having = append(having, sqlbuilder.Escape(hv))
}
}
if len(having) > 0 {
sb.Having(strings.Join(having, " AND "))
}
sb.OrderBy(orderClause(orders)...)
sb.Limit(limit)
if offset > 0 {
sb.Offset(offset)
}
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return fmt.Sprintf("matched AS (%s)", sql), args, nil
}
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace, read from the
// small trace-summary table.
func (b *scopedTraceStatementBuilder) buildRankedCTE(start, end uint64) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("trace_id", "min(start) AS t_start", "max(end) AS t_end")
sb.From(summaryTable())
sb.Where(
"trace_id GLOBAL IN (SELECT trace_id FROM matched)",
"end >= fromUnixTimestamp64Nano("+sb.Var(start)+")",
"start < fromUnixTimestamp64Nano("+sb.Var(end)+")",
)
sb.GroupBy("trace_id")
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return fmt.Sprintf("ranked AS (%s)", sql), args
}
// buildBucketsCTE builds `buckets`: the ts_bucket_start values the matched traces
// span, so the enrichment scan is primary-key pruned. No args.
func buildBucketsCTE() string {
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
return fmt.Sprintf("buckets AS (SELECT DISTINCT b AS ts_bucket FROM ranked "+
"ARRAY JOIN range("+
"toUInt64(intDiv(toUnixTimestamp(t_start), %d) * %d - %d), "+
"toUInt64(intDiv(toUnixTimestamp(t_end), %d) * %d + %d), "+
"%d) AS b)", adj, adj, adj, adj, adj, adj, adj)
}
// buildEnrichmentSelect builds the final SELECT: every per-trace column for the
// matched traces over their full extent, scanning only their buckets.
//
// Accepted discrepancy: matched ranks/paginates on window-clipped values (and, with a
// resource filter, only over fingerprint-matching spans), while this pass recomputes
// and ORDER BYs full-trace values — so a trace with activity outside the window or
// resource can sort differently than it ranked. Page membership is unaffected
// (LIMIT/OFFSET runs only in matched); rows still sort by the values the user sees.
// Ordering by matched's values instead would re-run the matched scan (ClickHouse
// re-executes a CTE per reference) without fixing the visible cross-page artifact.
func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(resolved []resolvedColumn, orders []listOrder) (string, []any) {
sb := sqlbuilder.NewSelectBuilder()
selects, selectArgs := selectAllColumns(resolved)
sb.Select(selects...)
sb.From(spanTable())
sb.Where(
"ts_bucket_start GLOBAL IN (SELECT ts_bucket FROM buckets)",
"trace_id GLOBAL IN (SELECT trace_id FROM ranked)",
)
sb.GroupBy("trace_id")
sb.OrderBy(orderClause(orders)...)
sql, builtArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
return sql, append(append([]any{}, selectArgs...), builtArgs...)
}
// buildHaving rewrites a trace-level HAVING expression to the matched-pass column
// aliases. The rewriter matches raw key text, so the trace. form is mapped alongside
// the bare name.
func (b *scopedTraceStatementBuilder) buildHaving(havingExpr string, orderableSet map[string]struct{}) (string, error) {
columnMap := make(map[string]string, len(orderableSet)*2)
for a := range orderableSet {
columnMap[a] = quoteAlias(a)
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
}
return querybuilder.NewHavingExpressionRewriter().Rewrite(havingExpr, columnMap)
}
// ---------------------------------------------------------------------------
// Small shared SQL-builder utilities
// ---------------------------------------------------------------------------
// spanTable is the fully-qualified span index table.
func spanTable() string {
return fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName)
}
// summaryTable is the fully-qualified trace-summary table.
func summaryTable() string {
return fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.TraceSummaryTableName)
}
// aggregateAliasSet is every trace-level column alias, used to classify filter keys
// as trace-level vs span-level. Derived from the scope's columns so a new column
// can't be forgotten; 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 {
if !c.SpanLevel {
set[c.Alias] = struct{}{}
}
}
return set
}
// orderableAliasSet is the subset of aliases computable in the matched pass — the
// only ones usable in ORDER BY and the aggregate filter.
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. Everything else is left to the
// enrichment scan.
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) 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 {
needed[name] = struct{}{}
}
}
return needed
}
// traceAggregateNames extracts the aggregate names a trace-level HAVING expression
// references. QueryStringToKeysSelectors emits an extra attribute-context fallback
// selector for context-prefixed keys (`trace.x` → attribute "trace.x"); only the
// 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 (e.g. span_count, trace_duration_nano).
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 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
}
}
return nil
}
// embedExpr inlines a resolved expr into sb, replacing each `?` placeholder with a
// builder Var so the args are tracked in appearance order. Resolved exprs carry
// values only as bound args, so every `?` is a placeholder; a count mismatch would
// silently shift args into the wrong slots — error out instead.
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
}
// windowWhere binds the time-window predicates to sb and returns them so the caller
// can add its own predicates in the same Where call.
func windowWhere(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64) []string {
return []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),
}
}
// orderClause renders the ORDER BY terms plus the trace_id tiebreak.
func orderClause(orders []listOrder) []string {
out := make([]string, 0, len(orders)+1)
for _, o := range orders {
out = append(out, fmt.Sprintf("%s %s", quoteAlias(o.alias), o.direction))
}
return append(out, "trace_id DESC")
}
// selectAllColumns renders `expr AS alias` for every resolved column, args in select
// order.
func selectAllColumns(resolved []resolvedColumn) ([]string, []any) {
selects := []string{"trace_id"}
var args []any
for _, rc := range resolved {
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
args = append(args, rc.args...)
}
return selects, args
}
// quoteAlias backticks an alias containing characters special to the SQL builder.
func quoteAlias(alias string) string {
if strings.ContainsAny(alias, ".$`") {
return "`" + alias + "`"
}
return alias
}

View File

@@ -0,0 +1,31 @@
package scopedtracesstatementbuilder
import (
"strings"
"testing"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/require"
)
// The full-pipeline golden tests live in pkg/statementbuilder/aistatementbuilder,
// which exercises this
// builder through its production provider pair. The tests here cover only what
// needs the package internals.
// embedExpr treats every `?` byte as a placeholder; a count/args mismatch (an expr
// carrying a literal `?`, or a dropped arg) must fail loudly instead of silently
// shifting every subsequent arg into the wrong placeholder.
func TestEmbedExpr_PlaceholderArgMismatch(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
out, err := embedExpr(sb, "x = ? AND y = ?", []any{1, 2})
require.NoError(t, err)
require.Equal(t, 2, strings.Count(out, "$"), "both placeholders bound as builder vars")
_, err = embedExpr(sb, "x = ? AND y LIKE 'a?b'", []any{1})
require.Error(t, err, "literal ? in the expr must not pass as a placeholder")
_, err = embedExpr(sb, "x = ?", []any{1, 2})
require.Error(t, err, "extra args must not be silently dropped")
}

View File

@@ -1168,6 +1168,27 @@ func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFi
return keys
}
// enrichWithGenAIKeys adds keys that can be queried for GenAI signals, even though they have not been ingested yet.
func enrichWithGenAIKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
for _, selector := range selectors {
if selector.Signal != telemetrytypes.SignalTraces && selector.Signal != telemetrytypes.SignalUnspecified {
continue
}
for name, def := range telemetrytypes.GenAIFieldDefinitions {
if len(keys[name]) > 0 {
continue // already resolved from ingested data
}
if !selectorMatchesIntrinsicField(selector, def) {
continue
}
keyCopy := def
keys[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
}
}
return keys
}
func selectorMatchesIntrinsicField(selector *telemetrytypes.FieldKeySelector, definition telemetrytypes.TelemetryFieldKey) bool {
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != definition.FieldContext {
return false
@@ -1253,6 +1274,9 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, orgID valuer.UUID, fie
applyBackwardCompatibleKeys(mapOfKeys)
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, selectors)
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, selectors)
}
return mapOfKeys, complete, nil
}
@@ -1331,6 +1355,9 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
applyBackwardCompatibleKeys(mapOfKeys)
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, fieldKeySelectors)
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, fieldKeySelectors)
}
return mapOfKeys, complete, nil
}

View File

@@ -44,8 +44,6 @@ var (
GCPServiceCloudSQLPostgres = ServiceID{valuer.NewString("cloudsql_postgres")}
GCPServiceMemorystoreRedis = ServiceID{valuer.NewString("memorystore_redis")}
GCPServiceComputeEngine = ServiceID{valuer.NewString("computeengine")}
GCPServiceGKE = ServiceID{valuer.NewString("gke")}
GCPServiceCloudStorage = ServiceID{valuer.NewString("cloudstorage")}
)
func (ServiceID) Enum() []any {
@@ -80,8 +78,6 @@ func (ServiceID) Enum() []any {
GCPServiceCloudSQLPostgres,
GCPServiceMemorystoreRedis,
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
}
}
@@ -122,8 +118,6 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
GCPServiceCloudSQLPostgres,
GCPServiceMemorystoreRedis,
GCPServiceComputeEngine,
GCPServiceGKE,
GCPServiceCloudStorage,
},
}

View File

@@ -16,18 +16,10 @@ import (
const (
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
GenAIRequestModel = "gen_ai.request.model"
GenAIProviderName = "gen_ai.provider.name"
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
SignozGenAICostInput = "_signoz.gen_ai.cost_input"
SignozGenAICostOutput = "_signoz.gen_ai.cost_output"
SignozGenAICostCacheRead = "_signoz.gen_ai.cost_cache_read"
SignozGenAICostCacheWrite = "_signoz.gen_ai.cost_cache_write"
SignozGenAITotalCost = "_signoz.gen_ai.total_cost"
)
var (

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"gopkg.in/yaml.v3"
)
@@ -83,11 +84,11 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
return &LLMPricingRuleProcessorConfig{
Attrs: LLMPricingRuleProcessorAttrs{
Model: GenAIRequestModel,
In: GenAIUsageInputTokens,
Out: GenAIUsageOutputTokens,
CacheRead: GenAIUsageCacheReadInputTokens,
CacheWrite: GenAIUsageCacheCreationInputTokens,
Model: telemetrytypes.GenAIRequestModel,
In: telemetrytypes.GenAIUsageInputTokens,
Out: telemetrytypes.GenAIUsageOutputTokens,
CacheRead: telemetrytypes.GenAIUsageCacheReadInputTokens,
CacheWrite: telemetrytypes.GenAIUsageCacheCreationInputTokens,
},
DefaultPricing: LLMPricingRuleProcessorDefaultPricing{
Rules: pricingRules,
@@ -97,7 +98,7 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
Out: SignozGenAICostOutput,
CacheRead: SignozGenAICostCacheRead,
CacheWrite: SignozGenAICostCacheWrite,
Total: SignozGenAITotalCost,
Total: telemetrytypes.SignozGenAITotalCost,
},
}
}

View File

@@ -9,6 +9,7 @@ type QueryType struct {
var (
QueryTypeUnknown = QueryType{valuer.NewString("unknown")}
QueryTypeBuilder = QueryType{valuer.NewString("builder_query")}
QueryTypeBuilderAI = QueryType{valuer.NewString("builder_ai_query")}
QueryTypeFormula = QueryType{valuer.NewString("builder_formula")}
QueryTypeSubQuery = QueryType{valuer.NewString("builder_sub_query")}
QueryTypeJoin = QueryType{valuer.NewString("builder_join")}
@@ -21,6 +22,7 @@ var (
func (QueryType) Enum() []any {
return []any{
QueryTypeBuilder,
QueryTypeBuilderAI,
QueryTypeFormula,
// Not yet supported.
// QueryTypeSubQuery,

View File

@@ -62,6 +62,13 @@ type queryEnvelopeBuilder struct {
Spec builderQuerySpec `json:"spec" description:"The builder query specification."`
}
// queryEnvelopeBuilderAI is the OpenAPI schema for a builder_ai_query QueryEnvelope.
// The spec is always a traces builder query (the signal is implied by the type).
type queryEnvelopeBuilderAI struct {
Type QueryType `json:"type" required:"true" description:"The type of the query."`
Spec QueryBuilderQuery[TraceAggregation] `json:"spec" description:"The AI builder query specification."`
}
// queryEnvelopeFormula is the OpenAPI schema for a QueryEnvelope with type=builder_formula.
type queryEnvelopeFormula struct {
Type QueryType `json:"type" required:"true" description:"The type of the query."`
@@ -100,6 +107,7 @@ var _ jsonschema.OneOfExposer = QueryEnvelope{}
func (QueryEnvelope) JSONSchemaOneOf() []any {
return []any{
queryEnvelopeBuilder{},
queryEnvelopeBuilderAI{},
queryEnvelopeFormula{},
// queryEnvelopeJoin{}, // deferred — see commented queryEnvelopeJoin above
queryEnvelopeTraceOperator{},
@@ -120,6 +128,7 @@ func (QueryEnvelope) PrepareJSONSchema(s *jsonschema.Schema) error {
"propertyName": "type",
"mapping": map[string]string{
QueryTypeBuilder.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilder",
QueryTypeBuilderAI.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeBuilderAI",
QueryTypeFormula.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeFormula",
QueryTypeTraceOperator.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopeTraceOperator",
QueryTypePromQL.StringValue(): "#/components/schemas/Querybuildertypesv5QueryEnvelopePromQL",
@@ -150,6 +159,16 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
}
q.Spec = spec
case QueryTypeBuilderAI:
// A dedicated AI query is always a traces builder query; the signal is
// implied by the type, so pin it rather than requiring the caller to send it.
var spec QueryBuilderQuery[TraceAggregation]
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
return err
}
spec.Signal = telemetrytypes.SignalTraces
q.Spec = spec
case QueryTypeFormula:
var spec QueryBuilderFormula
if err := json.Unmarshal(shadow.Spec, &spec); err != nil {
@@ -194,7 +213,7 @@ func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
"unknown query type %q",
shadow.Type,
).WithAdditional(
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, builder_trace_operator, promql, clickhouse_sql",
"Valid query types are: builder_query, builder_ai_query, builder_sub_query, builder_formula, builder_join, builder_trace_operator, promql, clickhouse_sql",
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
}

View File

@@ -641,7 +641,7 @@ func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
// Builder query names must be unique across the composite query.
queryNames := make(map[string]bool)
for _, envelope := range r.CompositeQuery.Queries {
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery || envelope.Type == QueryTypeBuilderAI {
name := envelope.GetQueryName()
if name != "" {
if queryNames[name] {
@@ -710,7 +710,7 @@ func (c *CompositeQuery) Validate(opts ...ValidationOption) error {
}
// Check name uniqueness for builder queries
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery {
if envelope.Type == QueryTypeBuilder || envelope.Type == QueryTypeSubQuery || envelope.Type == QueryTypeBuilderAI {
name := envelope.GetQueryName()
if name != "" {
if queryNames[name] {
@@ -744,6 +744,15 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
"unknown query spec type",
)
}
case QueryTypeBuilderAI:
spec, ok := envelope.Spec.(QueryBuilderQuery[TraceAggregation])
if !ok {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid AI builder query spec",
)
}
return spec.Validate(opts...)
case QueryTypeFormula:
spec, ok := envelope.Spec.(QueryBuilderFormula)
if !ok {
@@ -813,7 +822,7 @@ func validateQueryEnvelope(envelope QueryEnvelope, opts ...ValidationOption) err
"unknown query type: %s",
envelope.Type,
).WithAdditional(
"Valid query types are: builder_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
"Valid query types are: builder_query, builder_ai_query, builder_sub_query, builder_formula, builder_join, promql, clickhouse_sql, trace_operator",
).WithSuggestions(errors.NewValidReferences(errors.NounQueryTypes, QueryType{}.Enum()...))
}
}

View File

@@ -76,6 +76,7 @@ var (
"log": FieldContextLog,
"metric": FieldContextMetric,
"tracefield": FieldContextTrace,
"trace": FieldContextTrace,
}
)

View File

@@ -0,0 +1,43 @@
package telemetrytypes
// OpenTelemetry gen_ai semantic-convention attribute keys. Single source of truth
// shared by the AI query builder and the LLM pricing pipeline.
const (
GenAIRequestModel = "gen_ai.request.model"
GenAIToolName = "gen_ai.tool.name"
GenAIAgentName = "gen_ai.agent.name"
GenAIProviderName = "gen_ai.provider.name"
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
// SignozGenAITotalCost is not OTel semconv: it is the per-span total cost the
// SigNoz LLM pricing processor computes and attaches (see llmpricingruletypes).
SignozGenAITotalCost = "_signoz.gen_ai.total_cost"
)
// GenAIFieldDefinitions are the gen_ai semantic-convention span attributes the AI
// query builder relies on. They are surfaced by the metadata store for trace
// queries regardless of whether they have been ingested yet, so the AI gate/columns
// resolve on a fresh install (mirrors intrinsic metric keys). String keys are the
// gate; the usage keys are numeric.
var GenAIFieldDefinitions = map[string]TelemetryFieldKey{
GenAIRequestModel: {Name: GenAIRequestModel, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIToolName: {Name: GenAIToolName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIAgentName: {Name: GenAIAgentName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIProviderName: {Name: GenAIProviderName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIUsageInputTokens: {Name: GenAIUsageInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIUsageOutputTokens: {Name: GenAIUsageOutputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIUsageCacheReadInputTokens: {Name: GenAIUsageCacheReadInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIUsageCacheCreationInputTokens: {Name: GenAIUsageCacheCreationInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
SignozGenAITotalCost: {Name: SignozGenAITotalCost, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIInputMessages: {Name: GenAIInputMessages, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIOutputMessages: {Name: GenAIOutputMessages, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
}

View File

@@ -10,6 +10,7 @@ const wildcardSelector = "*"
var telemetryGrantQueryTypes = map[string]bool{
"builder_query": true,
"builder_ai_query": true,
"builder_sub_query": true,
"promql": false,
"clickhouse_sql": false,

View File

@@ -78,12 +78,15 @@ class BuilderQuery:
signal: str
name: str = "A"
source: str | None = None
query_type: str = "builder_query"
limit: int | None = None
offset: int | None = None
filter_expression: str | None = None
having_expression: str | None = None
select_fields: list[TelemetryFieldKey] | None = None
order: list[OrderBy] | None = None
aggregations: list[Aggregation | MetricAggregation] | None = None
group_by: list[TelemetryFieldKey] | None = None
step_interval: int | None = None
def to_dict(self) -> dict:
@@ -99,16 +102,20 @@ class BuilderQuery:
spec["offset"] = self.offset
if self.filter_expression:
spec["filter"] = {"expression": self.filter_expression}
if self.having_expression:
spec["having"] = {"expression": self.having_expression}
if self.select_fields:
spec["selectFields"] = [f.to_dict() for f in self.select_fields]
if self.order:
spec["order"] = [o.to_dict() if hasattr(o, "to_dict") else o for o in self.order]
if self.aggregations:
spec["aggregations"] = [agg.to_dict() if hasattr(agg, "to_dict") else agg for agg in self.aggregations]
if self.group_by:
spec["groupBy"] = [k.to_dict() for k in self.group_by]
if self.step_interval is not None:
spec["stepInterval"] = self.step_interval
return {"type": "builder_query", "spec": spec}
return {"type": self.query_type, "spec": spec}
@dataclass

129
tests/fixtures/querierai.py vendored Normal file
View File

@@ -0,0 +1,129 @@
"""
Trace builders for the querierai suite (query_type="builder_ai_query").
Every builder pins its spans a few seconds before the given `now` so
`query_window(now)` covers them, and tags them with the caller's service name so
tests do not interfere with each other's data.
"""
from datetime import datetime, timedelta
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def query_window(now: datetime) -> tuple[int, int]:
"""[now-10min, now+1min) in epoch millis — wide enough for every builder here."""
return (
int((now - timedelta(minutes=10)).timestamp() * 1000),
int((now + timedelta(minutes=1)).timestamp() * 1000),
)
def root_span(*, now: datetime, trace_id: str, span_id: str, resources: dict[str, str], duration_s: float) -> Traces:
"""The non-gen_ai entry span every AI trace hangs off. On its own (no gen_ai
children) it is exactly the trace the AI gate must exclude."""
return Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=duration_s),
trace_id=trace_id,
span_id=span_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"},
)
def ai_trace(
*,
now: datetime,
service: str,
user: str,
in_tokens: int | None,
out_tokens: int,
cost: float,
model: str = "gpt-4o-mini",
environment: str = "production",
) -> list[Traces]:
"""A minimal AI trace: root span + one LLM span with gen_ai attributes.
in_tokens=None omits the input-tokens attribute entirely (not zero)."""
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
resources = {"service.name": service, "deployment.environment": environment}
attributes = {
"gen_ai.request.model": model,
"gen_ai.system": "openai",
"gen_ai.user.id": user,
# numeric values land in attributes_number
"gen_ai.usage.output_tokens": out_tokens,
"_signoz.gen_ai.total_cost": cost,
}
if in_tokens is not None:
attributes["gen_ai.usage.input_tokens"] = in_tokens
llm = Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="chat gpt-4o-mini",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes=attributes,
)
return [
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=1.1),
llm,
]
def ai_trace_mixed_spans(*, now: datetime, service: str, user: str) -> list[Traces]:
"""
Root + one LLM span + one tool span + one agent span. The gate matches all three
child spans, but only the LLM span carries gen_ai.request.model.
"""
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
resources = {"service.name": service, "deployment.environment": "production"}
def child(name: str, kind: TracesKind, attributes: dict, offset_s: float) -> Traces:
return Traces(
timestamp=now - timedelta(seconds=offset_s),
duration=timedelta(seconds=0.5),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name=name,
kind=kind,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes=attributes,
)
return [
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
child(
"chat gpt-4o-mini",
TracesKind.SPAN_KIND_CLIENT,
{
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.system": "openai",
"gen_ai.user.id": user,
"gen_ai.usage.input_tokens": 100,
"gen_ai.usage.output_tokens": 20,
},
4,
),
child(
"execute_tool",
TracesKind.SPAN_KIND_INTERNAL,
{"gen_ai.tool.name": "get_weather", "gen_ai.tool.type": "function"},
3,
),
child("agent.step", TracesKind.SPAN_KIND_INTERNAL, {"gen_ai.agent.name": "chat-agent"}, 2),
]

View File

@@ -0,0 +1,681 @@
"""
Integration tests for query_type="builder_ai_query" over the traces signal.
Data shape (generic OTel gen_ai semantic conventions):
- a root span (no gen_ai attributes)
- an LLM span carrying gen_ai.request.model (str) and numeric usage attributes
(gen_ai.usage.input_tokens / output_tokens / cost) plus gen_ai.user.id
Each test tags its spans with a unique service.name and filters on it, so tests do
not interfere with each other's data. Builders shared across the suite (query window,
ai_trace, ai_trace_mixed_spans) live in fixtures/querierai.py; one-off shapes are
built right above the test that uses them.
"""
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
BuilderQuery,
OrderBy,
RequestType,
TelemetryFieldKey,
make_query_request,
)
from fixtures.querierai import (
ai_trace,
ai_trace_mixed_spans,
query_window,
root_span,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_ai_list_excludes_non_ai(
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-list panel (requestType="trace"): returns AI traces and excludes the
non-AI trace. Asserts on the raw response payload to stay agnostic to the exact
row schema.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-list"
ai = ai_trace(now=now, service=service, user="alice", in_tokens=100, out_tokens=20, cost=0.5)
# a lone root span, i.e. a trace with no gen_ai spans at all
non_ai = root_span(
now=now,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
resources={"service.name": service},
duration_s=1,
)
ai_trace_id = ai[0].trace_id
insert_traces([*ai, non_ai])
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}'",
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
body = json.dumps(response.json())
assert ai_trace_id in body, f"expected AI trace {ai_trace_id} in list response"
assert non_ai.trace_id not in body, f"non-AI trace {non_ai.trace_id} should be excluded by the gate"
def test_ai_list_having_aggregate_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:
"""
Aggregate filter written in the SAME filter box: the span-level predicate narrows
to the service, the trace-level `output_tokens > 100` keeps the large-token
trace and drops the small one (split internally into WHERE + HAVING). Both
spellings of a trace-level aggregate — bare and `trace.` — behave identically
(unit tests pin them to byte-identical SQL; this covers the wiring once
end-to-end). An output-only aggregate is rejected under either spelling.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-having"
small = ai_trace(now=now, service=service, user="alice", in_tokens=10, out_tokens=20, cost=0.1)
large = ai_trace(now=now, service=service, user="bob", in_tokens=10, out_tokens=500, cost=0.2)
small_id = small[0].trace_id
large_id = large[0].trace_id
insert_traces(small + large)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
for spelling in ("output_tokens", "trace.output_tokens"):
query = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression=f"service.name = '{service}' AND {spelling} > 100",
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, f"{spelling}: {response.text}"
body = json.dumps(response.json())
assert large_id in body, f"{spelling}: trace with 500 out-tokens should pass > 100"
assert small_id not in body, f"{spelling}: trace with 20 out-tokens should be filtered out by HAVING"
# output-only aggregate gets the targeted rejection.
bad = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression="trace.span_count > 3",
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [bad.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "cannot be used" in response.text
def test_ai_list_order_limit_offset(
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 list honors order by (aggregate column) + limit + offset (pagination)."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-order"
traces: list[Traces] = []
for out in (100, 200, 300, 400, 500):
traces += ai_trace(now=now, service=service, user="u", in_tokens=10, out_tokens=out, cost=0.1)
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
def page(offset: int) -> list[int]:
query = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
filter_expression=f"service.name = '{service}'",
order=[OrderBy(key=TelemetryFieldKey(name="output_tokens"), direction="desc")],
limit=2,
offset=offset,
)
resp = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert resp.status_code == HTTPStatus.OK, resp.text
rows = resp.json()["data"]["data"]["results"][0]["rows"]
return [int(r["data"]["output_tokens"]) for r in rows]
assert page(0) == [500, 400], "first page: highest output_tokens, desc"
assert page(2) == [300, 200], "second page (offset 2): next two, desc"
def test_ai_span_list_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:
"""Span list honors limit (delegated raw path): 6 gen_ai spans available, capped to 4."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-spanlimit"
insert_traces(ai_trace_mixed_spans(now=now, service=service, user="a") + ai_trace_mixed_spans(now=now, service=service, user="b"))
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}'",
limit=4,
)
resp = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type=RequestType.RAW)
assert resp.status_code == HTTPStatus.OK, resp.text
rows = resp.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 4, f"limit should cap at 4 (6 gen_ai spans available), got {len(rows)}"
def test_ai_span_list_excludes_non_gen_ai_spans(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Span list (requestType=raw): returns only the gen_ai spans (LLM/tool/agent); the
root span of the same trace (no gen_ai attributes) is excluded by the span-level gate.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-spanlist"
insert_traces(ai_trace_mixed_spans(now=now, service=service, user="alice"))
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}'",
select_fields=[TelemetryFieldKey(name="name", field_context="span")],
limit=50,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type=RequestType.RAW)
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"]
names = sorted(r["data"]["name"] for r in rows)
assert names == ["agent.step", "chat gpt-4o-mini", "execute_tool"], names
assert "POST /api/chat" not in names # root span excluded
def test_ai_list_having_or_aggregates(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Two trace-level aggregates OR-ed within the filter box (regression guard for OR-group
whitespace handling): output_tokens > 100 OR input_tokens > 1000 keeps only the
large-output trace (input_tokens is 10 for both, so that branch never matches).
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-having-or"
small = ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1)
large = ai_trace(now=now, service=service, user="b", in_tokens=10, out_tokens=500, cost=0.2)
small_id, large_id = small[0].trace_id, large[0].trace_id
insert_traces(small + large)
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}' AND (output_tokens > 100 OR input_tokens > 1000)",
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
body = json.dumps(response.json())
assert large_id in body
assert small_id not in body
def test_ai_list_resource_filter_isolates_by_fingerprint(
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 resource attribute in the filter is pulled into the __resource_filter fingerprint
CTE (see maybeAttachResourceFilter). Two traces on the same service but different
deployment.environment: `resource.deployment.environment = 'production'` must keep
the production trace and drop the staging one — the fingerprint prune isolates by
the resource, not by any span attribute.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-resfilter"
prod = ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1, environment="production")
stag = ai_trace(now=now, service=service, user="b", in_tokens=10, out_tokens=20, cost=0.1, environment="staging")
prod_id, stag_id = prod[0].trace_id, stag[0].trace_id
insert_traces(prod + stag)
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"resource.service.name = '{service}' AND resource.deployment.environment = 'production'"),
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
body = json.dumps(response.json())
assert prod_id in body, "production trace should match the resource filter"
assert stag_id not in body, "staging trace should be excluded by the resource fingerprint prune"
def test_ai_list_rejects_aggregate_or_span_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:
"""
Aggregate (HAVING) columns may not be OR-ed with span-level keys in the trace
list; a span-OR-span filter is fine.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-orfilter"
# seed a trace so service.name resolves as a known key in this window (resource
# keys are discovered from ingested data).
insert_traces(ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms, end_ms = query_window(now)
# aggregate OR span -> rejected
bad = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
limit=10,
filter_expression=f"output_tokens > 1000 OR service.name = '{service}'",
)
response = make_query_request(signoz, token, start_ms, end_ms, [bad.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "cannot be combined" in response.text
# span OR span -> accepted (result content doesn't matter; just not an error)
ok = BuilderQuery(
signal="traces",
query_type="builder_ai_query",
name="A",
limit=10,
filter_expression=f"service.name = '{service}' OR has_error = true",
)
response = make_query_request(signoz, token, start_ms, end_ms, [ok.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
def test_ai_list_nested_group_span_or_and_aggregate(
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 complex filter that mixes all three routing paths in one expression:
service.name = X AND (has_error = true OR gen_ai.request.model = 'gpt-4o') AND total_tokens > 100
The nested (span OR span) group must not flatten (precedence), the span predicates
go to WHERE as a trace-existence check, and the new `total_tokens` aggregate goes to
HAVING. Three traces isolate each discriminator:
- t_ok: gpt-4o, out=500 -> OR matches (model) AND total_tokens>100 -> IN
- t_or_miss: gpt-4o-mini, out=500 -> OR fails (no error, wrong model) -> OUT
- t_agg_miss: gpt-4o, out=20 -> OR matches but total_tokens<=100 -> OUT
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-nested"
t_ok = ai_trace(now=now, service=service, user="a", model="gpt-4o", in_tokens=10, out_tokens=500, cost=0.1)
t_or_miss = ai_trace(now=now, service=service, user="b", model="gpt-4o-mini", in_tokens=10, out_tokens=500, cost=0.1)
t_agg_miss = ai_trace(now=now, service=service, user="c", model="gpt-4o", in_tokens=10, out_tokens=20, cost=0.1)
insert_traces(t_ok + t_or_miss + t_agg_miss)
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}' AND (has_error = true OR gen_ai.request.model = 'gpt-4o') AND total_tokens > 100"),
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
body = json.dumps(response.json())
assert t_ok[0].trace_id in body
assert t_or_miss[0].trace_id not in body, "nested (span OR span) group must exclude the wrong-model, no-error trace"
assert t_agg_miss[0].trace_id not in body, "HAVING total_tokens > 100 must exclude the low-token trace"
def test_ai_list_rejects_unknown_aggregate_key(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""A trace-level filter on an unknown aggregate name is rejected, not silently run."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
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",
limit=10,
filter_expression="trace.bogus_tokens > 1",
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_ai_list_rejects_order_by_span_attribute(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""Only gen_ai-scoped aggregates are orderable; ordering by a span/resource key errors."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
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",
limit=5,
order=[OrderBy(key=TelemetryFieldKey(name="service.name"), direction="asc")],
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "order key" in response.text
def test_ai_list_total_tokens_output_only(
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 trace whose LLM span carries only output tokens (no input-tokens attribute at
all) must still total: total_tokens is coalesce(sum(in),0)+coalesce(sum(out),0),
since sum over an absent attribute is NULL and NULL + n = NULL in ClickHouse.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-total-coalesce"
insert_traces(ai_trace(now=now, service=service, user="a", in_tokens=None, out_tokens=300, cost=0.1))
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}'",
limit=10,
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 1, f"expected one trace, got: {rows}"
data = rows[0]["data"]
assert data["input_tokens"] is None, data # attribute absent -> NULL, not 0
assert data["output_tokens"] == 300, data
assert data["total_tokens"] == 300, f"total must coalesce the missing input side: {data}"
def test_ai_list_variable_in_aggregate_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:
"""A query variable in a trace-level condition is substituted into the HAVING."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-having-var"
small = ai_trace(now=now, service=service, user="a", in_tokens=10, out_tokens=20, cost=0.1)
large = ai_trace(now=now, service=service, user="b", in_tokens=10, out_tokens=500, cost=0.2)
small_id, large_id = small[0].trace_id, large[0].trace_id
insert_traces(small + large)
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}' AND trace.output_tokens > $threshold",
limit=10,
)
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[query.to_dict()],
request_type="trace",
variables={"threshold": {"type": "custom", "value": 100}},
)
assert response.status_code == HTTPStatus.OK, response.text
body = json.dumps(response.json())
assert large_id in body
assert small_id not in body
def test_ai_list_messages_first_input_last_output(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
`input` is the FIRST LLM span's prompt (argMin over timestamp) and `output` is the
LAST LLM span's answer (argMax) — the question -> final-answer preview.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-messages"
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
resources = {"service.name": service}
def llm(offset_s: float, prompt: str, answer: str) -> 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.input.messages": prompt,
"gen_ai.output.messages": answer,
},
)
# earlier call is the "first" (its input is the prompt), later call is the "last"
# (its output is the final answer).
insert_traces(
[
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
llm(4, "first prompt", "first answer"),
llm(2, "second prompt", "second answer"),
]
)
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",
limit=10,
filter_expression=f"service.name = '{service}'",
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 1, f"expected one trace, got: {rows}"
data = rows[0]["data"]
assert data["input"] == "first prompt", f"input should be the earliest call's prompt: {data}"
assert data["output"] == "second answer", f"output should be the latest call's answer: {data}"
def test_ai_list_enrichment_values(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
End-to-end values of the derived per-trace columns (only integration can check that
ClickHouse computes uniqIf / sum+sum / countIf(predicate) correctly, not just that
the SQL is shaped right). One trace: root + 1 errored LLM + 3 tool spans
(get_weather x2, get_time x1) + 1 agent span. The tool and agent spans are in the
gen_ai gate but carry no request.model, so llm_call_count stays 1 while span_count
counts them all.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service = "ai-it-metrics"
trace_id = TraceIdGenerator.trace_id()
root_id = TraceIdGenerator.span_id()
resources = {"service.name": service}
def tool(name: str, offset_s: float) -> Traces:
return Traces(
timestamp=now - timedelta(seconds=offset_s),
duration=timedelta(seconds=0.2),
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": name, "gen_ai.tool.type": "function"},
)
llm = Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=2),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="chat gpt-4o-mini",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_ERROR, # -> has_error, drives error_count
resources=resources,
attributes={
"gen_ai.request.model": "gpt-4o-mini",
"gen_ai.usage.input_tokens": 100,
"gen_ai.usage.output_tokens": 20,
"_signoz.gen_ai.total_cost": 0.5,
},
)
agent = Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=0.5),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id=root_id,
name="agent.step",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources=resources,
attributes={"gen_ai.agent.name": "chat-agent"},
)
insert_traces(
[
root_span(now=now, trace_id=trace_id, span_id=root_id, resources=resources, duration_s=4),
llm,
tool("get_weather", 3),
tool("get_weather", 2.5),
tool("get_time", 2),
agent,
]
)
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",
limit=10,
filter_expression=f"service.name = '{service}'",
)
response = make_query_request(signoz, token, start_ms, end_ms, [query.to_dict()], request_type="trace")
assert response.status_code == HTTPStatus.OK, response.text
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 1, f"expected one trace, got: {rows}"
data = rows[0]["data"]
assert data["span_count"] == 6, data # root + llm + 3 tools + agent
assert data["llm_call_count"] == 1, data # only the request.model span, not tool/agent
assert data["tool_call_count"] == 3, data # all three tool spans
assert data["distinct_tool_count"] == 2, data # get_weather, get_time
assert data["input_tokens"] == 100, data
assert data["output_tokens"] == 20, data
assert data["total_tokens"] == 120, data # input + output
assert data["estimated_total_cost"] == pytest.approx(0.5), data
assert data["error_count"] == 1, data # the errored LLM span
assert data["max_llm_duration_nano"] > 0, data # scoped max over LLM spans