Compare commits

...

7 Commits

Author SHA1 Message Date
nityanandagohain
dcbd3e0747 chore: improvements for skip fingerprint 2026-06-03 15:55:11 +05:30
Abhi kumar
987844dbc8 refactor(types): tighten MetricQueryRangeSuccessResponse shape (#11562)
Replaces the `SuccessResponse<...> & { warning?: Warning; meta?: ExecStats }`
intersection with a single interface that extends SuccessResponse and declares
warning + meta on its own — no stitched intersection. Also types `params` as
`QueryRangeRequestV5` (was `unknown`), so callers can drop the redundant
`as QueryRangeRequestV5` casts.

Knock-on updates: 13 consumers switched from the raw
`SuccessResponse<MetricRangePayloadProps, unknown>` shape to the new
`MetricQueryRangeSuccessResponse`; 3 prod casts removed;
convertV5ResponseToLegacy and getTimeRange tightened their accept-params types.
2026-06-03 07:55:44 +00:00
primus-bot[bot]
e0ad7e487a chore(release): bump to v0.127.0 (#11558)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-06-03 07:22:32 +00:00
Ashwin Bhatkal
af72a4118b fix: resolve UX regressions across dashboards, metrics and alerts pages after component migration (#11546)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: resolve UX regressions across dashboards, metrics and alerts pages after component migration

- NewSelect: make the @signozhq checkbox label fill the row (min-width: 0)
  so long option text truncates with an ellipsis; size Only/Toggle buttons to
  the resting row height so hover no longer grows the row
- PlannedDowntime: reset field-label styles bleeding into the migrated
  RadioGroup option labels and add spacing between options
- Page-level contrast, spacing, alignment and icon-size fixes across
  dashboard settings, metrics explorer, alert rules, routing policies,
  auto-refresh and the time picker

* fix(multiselect): surface clear icon and remove dropdown double-scroll

- add .ant-select-clear color override so the multiselect clear (x) icon is
  visible on the dark selector (the single-select had it, multiselect didn't)
- raise the dropdown max-height so the react-virtuoso list (<=300px) plus the
  header/footer fits, leaving the list as the single scroller instead of the
  list and the container both scrolling
- StatsCard: drop the unnecessary !important on .count-label color

* fix: success colors
2026-06-03 05:16:01 +00:00
Nityananda Gohain
dc6a1d0a4e fix: skip resource filter if it exceeds a threshold (#11524)
* fix: skip resource filter if it exceeds a threshold

* fix: test cleanup

* fix: test cleanup

* fix: more cleanup

* fix: tests
2026-06-03 05:08:40 +00:00
Aditya Singh
2e60ab0f81 fix(logs): dedupe body/timestamp columns in explorer picker (#11553)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: duplicate body issue

* fix: update test
2026-06-02 20:15:32 +00:00
SagarRajput-7
29ed44a8ce fix(sso): send empty object when clearing group/domain mappings on save (#11550) 2026-06-02 19:30:09 +00:00
57 changed files with 1642 additions and 347 deletions

View File

@@ -52,6 +52,7 @@ jobs:
- rootuser
- serviceaccount
- querier_json_body
- querier_skip_resource_fingerprint
- ttl
sqlstore-provider:
- postgres

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.126.1
image: signoz/signoz:v0.127.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -213,7 +213,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
entrypoint:
- /bin/sh
command:
@@ -241,7 +241,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.126.1
image: signoz/signoz:v0.127.0
ports:
- "8080:8080" # signoz port
volumes:
@@ -139,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
entrypoint:
- /bin/sh
command:
@@ -167,7 +167,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.5
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.126.1}
image: signoz/signoz:${VERSION:-v0.127.0}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -204,7 +204,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.126.1}
image: signoz/signoz:${VERSION:-v0.127.0}
container_name: signoz
ports:
- "8080:8080" # signoz port
@@ -132,7 +132,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.5}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -349,7 +349,7 @@ function convertV5DataByType(
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function convertV5ResponseToLegacy(
v5Response: SuccessResponse<MetricRangePayloadV5>,
v5Response: SuccessResponse<MetricRangePayloadV5, QueryRangeRequestV5>,
legendMap: Record<string, string>,
formatForWeb?: boolean,
): SuccessResponse<MetricRangePayloadV3> & { warning?: Warning } {
@@ -357,7 +357,7 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
(params as QueryRangeRequestV5)?.compositeQuery?.queries
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {

View File

@@ -359,8 +359,7 @@ function CustomTimePickerPopoverContent({
<Clock
color={Color.BG_ROBIN_400}
className="timezone-container__clock-icon"
height={12}
width={12}
size={14}
/>
<span className="timezone__name">{timezone.name}</span>

View File

@@ -144,10 +144,7 @@ function AddedFields({
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
isRequired={
requiredFields.includes(field.name) ||
requiredFields.includes(field.key as string)
}
isRequired={requiredFields.includes(field.key as string)}
/>
))}
</SortableContext>

View File

@@ -25,7 +25,7 @@ describe('AddedFields — requiredFields', () => {
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(3);
});
it('hides the Remove button for fields whose name is in requiredFields', () => {
it('hides the Remove button for fields whose composite key is in requiredFields', () => {
const fields = [makeField('a'), makeField('b'), makeField('c')];
render(
@@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['a', 'c']}
requiredFields={['log.a', 'log.c']}
/>,
);
@@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['a']}
requiredFields={['log.a']}
/>,
);
@@ -58,9 +58,9 @@ describe('AddedFields — requiredFields', () => {
expect(screen.getByText('b')).toBeInTheDocument();
});
it('locks all variants of a required name regardless of fieldContext', () => {
// Two `body` fields with different contexts — both should lock when
// `body` is in requiredFields.
it('locks ONLY the canonical variant — a same-name field from another context stays removable', () => {
// Two `body` fields with different contexts. requiredFields holds the
// canonical composite key only, so the attribute variant is deletable.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
@@ -68,41 +68,6 @@ describe('AddedFields — requiredFields', () => {
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['body']}
/>,
);
// Both 'body' variants locked → zero Remove buttons.
expect(screen.queryAllByRole('button', { name: /remove/i })).toHaveLength(0);
});
it('treats requiredFields as exact-name match (substring does not lock)', () => {
const fields = [makeField('body'), makeField('body_extra')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['body']}
/>,
);
// 'body' locked, 'body_extra' removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
it('also accepts composite IDs in requiredFields (locks a specific variant)', () => {
// Two `body` fields with different contexts.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
// Composite ID — locks ONLY the log variant, attribute variant stays
// removable.
requiredFields={['log.body']}
/>,
);
@@ -110,4 +75,37 @@ describe('AddedFields — requiredFields', () => {
// One Remove button: the attribute variant. log variant is locked.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
it('does not lock anything when a bare name is passed (composite key required)', () => {
// Bare `body` no longer matches — matching is composite-key only now.
const fields = [makeField('body', 'log'), makeField('body', 'attribute')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['body']}
/>,
);
// Neither variant locked → both removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2);
});
it('treats requiredFields as exact composite-key match (substring does not lock)', () => {
const fields = [makeField('body'), makeField('body_extra')];
render(
<AddedFields
inputValue=""
fields={fields}
onFieldsChange={jest.fn()}
requiredFields={['log.body']}
/>,
);
// 'log.body' locked, 'log.body_extra' removable.
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
});
});

View File

@@ -10,9 +10,9 @@ jest.mock('providers/Timezone', () => ({
}),
}));
const field = (name: string): IField => ({
const field = (name: string, type = ''): IField => ({
name,
type: '',
type,
dataType: 'string',
});
@@ -38,18 +38,16 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
useLogsTableColumns({
fields: [
field('service.name'),
field('body'),
field('body', 'log'),
field('request.id'),
field('timestamp'),
field('timestamp', 'log'),
],
fontSize: FontSize.SMALL,
}),
);
// body/timestamp are NOT pinned to fixed positions — they appear where the
// caller placed them in `fields`. body/timestamp use composite IDs
// ('log.body', 'log.timestamp') since their fieldContext is fixed; user
// fields here have empty `type` so their composite collapses to bare name.
// body/timestamp appear where the caller placed them, keyed by their
// composite IDs ('log.*'); contextless user fields collapse to bare name.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'service.name',
@@ -59,6 +57,43 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
]);
});
it('renders a same-name field from another context as a DISTINCT column (no collision)', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body', 'log'), field('body', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
// Attribute variant is its own column, not a duplicate 'log.body'.
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.body',
'attribute.body',
]);
expect(byId.get('log.body')?.enableRemove).toBe(false);
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
});
it('applies the same distinct-column treatment to timestamp variants', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('timestamp', 'log'), field('timestamp', 'attribute')],
fontSize: FontSize.SMALL,
}),
);
const byId = new Map(result.current.map((c) => [c.id, c]));
expect(result.current.map((c) => c.id)).toStrictEqual([
'state-indicator',
'log.timestamp',
'attribute.timestamp',
]);
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
});
it('skips the synthetic "id" field name', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
@@ -77,7 +112,11 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
it('uses the special body/timestamp coldefs (canBeHidden=false), not the generic user field def', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body'), field('timestamp'), field('user_field')],
fields: [
field('body', 'log'),
field('timestamp', 'log'),
field('user_field'),
],
fontSize: FontSize.SMALL,
}),
);

View File

@@ -96,15 +96,18 @@ export function useLogsTableColumns({
),
});
// Match body/timestamp by composite key, not bare name — else a variant
// like `attribute.body` collapses onto `log.body`, duplicating the column.
const fieldCols = fields
.map((f): TableColumnDef<ILog> | null => {
if (f.name === 'id') {
return null;
}
if (f.name === 'timestamp') {
const compositeKey = buildCompositeKey(f.name, f.type);
if (compositeKey === timestampCol.id) {
return timestampCol;
}
if (f.name === 'body') {
if (compositeKey === bodyCol.id) {
return bodyCol;
}
return makeUserFieldCol(f);

View File

@@ -109,6 +109,16 @@ $custom-border-color: #2c3044;
color: color-mix(in srgb, var(--l2-foreground) 45%, transparent);
}
.ant-select-clear {
background-color: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
&:hover {
color: var(--l1-foreground);
}
}
// Customize tags in multiselect (dark mode by default)
.ant-select-selection-item {
background-color: var(--l1-border);
@@ -392,7 +402,9 @@ $custom-border-color: #2c3044;
// Custom dropdown styles for multi-select
.custom-multiselect-dropdown {
padding: 8px 0 0 0;
max-height: 350px;
// Tall enough to hold the react-virtuoso list (<=300px) + header/footer
// so only the list scrolls (avoids a second scrollbar on this container).
max-height: 410px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
@@ -483,8 +495,12 @@ $custom-border-color: #2c3044;
.option-checkbox {
width: 100%;
> span:not(.ant-checkbox) {
width: 100%;
// @signozhq/ui Checkbox renders children inside a <label> that is
// content-sized by default. Make it fill the row (min-width: 0 lets it
// shrink) so the option text below can truncate instead of overflowing.
> label {
flex: 1 1 auto;
min-width: 0;
}
.all-option-text {
@@ -501,7 +517,12 @@ $custom-border-color: #2c3044;
width: 100%;
.option-label-text {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.option-badge {
@@ -514,26 +535,30 @@ $custom-border-color: #2c3044;
}
}
.only-btn {
display: none;
}
// Size the buttons to the row's resting content height (20px) and fully
// override antd's default 32px Button box, so revealing them on hover
// never changes the row height.
.only-btn,
.toggle-btn {
display: none;
}
align-items: center;
justify-content: center;
height: 18px;
min-height: 0;
padding: 0 6px;
font-size: 12px;
line-height: 1;
border: none;
box-shadow: none;
.only-btn:hover {
background-color: unset;
}
.toggle-btn:hover {
background-color: unset;
&:hover {
background-color: unset;
}
}
.option-content:hover {
.only-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.toggle-btn {
display: none;
@@ -548,9 +573,6 @@ $custom-border-color: #2c3044;
.option-checkbox:hover {
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 21px;
}
.option-badge {
display: none;

View File

@@ -67,8 +67,8 @@
gap: 4px;
&--success {
background: color-mix(in srgb, var(--success-background) 10%, transparent);
color: var(--success-foreground);
background: color-mix(in srgb, var(--text-forest-500) 10%, transparent);
color: var(--text-forest-400);
}
&--error {

View File

@@ -153,6 +153,7 @@
font-size: 10px;
color: var(--l2-foreground);
margin-top: 4px;
display: block;
}
}

View File

@@ -47,18 +47,10 @@
}
.ant-tabs-tab-active {
.overview-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.variables-btn {
border-radius: 2px 0px 0px 2px;
background: var(--l1-border);
}
.overview-btn,
.variables-btn,
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
color: var(--primary-background);
background: var(--l1-border);
}
}

View File

@@ -127,6 +127,15 @@
align-items: center;
justify-content: center;
gap: 4px;
.sidenav-beta-tag {
margin-left: 4px;
}
div {
display: flex;
align-items: center;
}
}
.variable-type-btn + .variable-type-btn {
@@ -177,6 +186,7 @@
.multiple-values-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {
@@ -193,6 +203,7 @@
.all-option-section {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0;
.typography-variables {

View File

@@ -518,7 +518,6 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}
@@ -614,7 +613,6 @@ function VariableItem({
size={14}
style={{
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
marginTop: 1,
}}
/>
}

View File

@@ -1,8 +1,7 @@
import { renderHook } from '@testing-library/react';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { usePanelContextMenu } from '../usePanelContextMenu';
@@ -47,10 +46,7 @@ const mockWidget = { id: 'w-1', query: {} } as unknown as Widgets;
const mockQueryResponse = {
data: undefined,
isLoading: false,
} as unknown as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
} as unknown as UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
describe('usePanelContextMenu', () => {
beforeEach(() => {

View File

@@ -10,17 +10,13 @@ import {
PopoverPosition,
useCoordinates,
} from 'periscope/components/ContextMenu';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesContextMenuParams {
widget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
enableDrillDown?: boolean;
}

View File

@@ -19,6 +19,7 @@
}
.ant-btn-default {
border-color: transparent;
box-shadow: none;
}
}
.ant-tabs-tab-active {

View File

@@ -5,9 +5,11 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import {
MetricQueryRangeSuccessResponse,
MetricRangePayloadProps,
} from 'types/api/metrics/getQueryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import uPlot from 'uplot';
@@ -21,10 +23,7 @@ export interface GraphVisibilityLegendEntryProps {
export interface WidgetGraphComponentProps {
widget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
errorMessage: string | undefined;
version?: string;
threshold?: ReactNode;

View File

@@ -1,8 +1,7 @@
import { UseQueryResult } from 'react-query';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { SuccessResponse } from 'types/api';
import { ContextLinksData, Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
export type GridValueComponentProps = {
@@ -13,10 +12,7 @@ export type GridValueComponentProps = {
thresholds?: ThresholdProps[];
// Context menu related props
widget?: Widgets;
queryResponse?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryResponse?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
contextLinks?: ContextLinksData;
enableDrillDown?: boolean;
};

View File

@@ -28,9 +28,8 @@ import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import { UpdateTimeInterval } from 'store/actions';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
function WidgetGraph({
@@ -202,10 +201,7 @@ function WidgetGraph({
interface WidgetGraphProps {
selectedWidget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;

View File

@@ -1,11 +1,12 @@
import { QueryRangeRequestV5 } from 'api/v5/v5';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Column, QueryData, QueryDataV3 } from 'types/api/widgets/getQuery';
// eslint-disable-next-line sonarjs/cognitive-complexity
export function populateMultipleResults(
responseData: SuccessResponse<MetricRangePayloadProps, unknown>,
): SuccessResponse<MetricRangePayloadProps, unknown> {
responseData: SuccessResponse<MetricRangePayloadProps, QueryRangeRequestV5>,
): SuccessResponse<MetricRangePayloadProps, QueryRangeRequestV5> {
const queryResults = responseData?.payload?.data?.newResult?.data?.result;
const allFormattedResults: QueryData[] = [];
@@ -66,17 +67,19 @@ export function populateMultipleResults(
}
// Create a copy instead of mutating the original
const updatedResponseData: SuccessResponse<MetricRangePayloadProps, unknown> =
{
...responseData,
payload: {
...responseData.payload,
data: {
...responseData.payload.data,
result: allFormattedResults,
},
const updatedResponseData: SuccessResponse<
MetricRangePayloadProps,
QueryRangeRequestV5
> = {
...responseData,
payload: {
...responseData.payload,
data: {
...responseData.payload.data,
result: allFormattedResults,
},
};
},
};
return updatedResponseData;
}

View File

@@ -52,7 +52,6 @@ import {
getSelectedWidgetIndex,
} from 'providers/Dashboard/util';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
ContextLinksData,
@@ -61,7 +60,7 @@ import {
} from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { IField } from 'types/api/logs/fields';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -398,7 +397,7 @@ function NewWidget({
// State to hold query response for sharing between left and right containers
const [queryResponse, setQueryResponse] = useState<
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
UseQueryResult<MetricQueryRangeSuccessResponse, Error>
>(null as any);
// request data should be handled by the parent and the child components should consume the same

View File

@@ -2,9 +2,8 @@ import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { SuccessResponse, Warning } from 'types/api';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { timePreferance } from './RightContainer/timeItems';
@@ -29,9 +28,7 @@ export interface WidgetGraphProps {
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
isLoadingPanelData: boolean;
setQueryResponse?: Dispatch<
SetStateAction<
UseQueryResult<SuccessResponse<MetricRangePayloadProps, unknown>, Error>
>
SetStateAction<UseQueryResult<MetricQueryRangeSuccessResponse, Error>>
>;
enableDrillDown?: boolean;
dashboardData: Dashboard | undefined;
@@ -39,12 +36,7 @@ export interface WidgetGraphProps {
}
export type WidgetGraphContainerProps = {
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown> & {
warning?: Warning;
},
Error
>;
queryResponse: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
selectedGraph: PANEL_TYPES;
selectedWidget: Widgets;

View File

@@ -67,13 +67,23 @@ describe('ensureLogsRequiredColumns', () => {
]);
});
it('does not duplicate if a required column appears twice in the input', () => {
// Tolerant of malformed input — invariant only adds *missing* required
// columns; it does not deduplicate existing entries (that's a separate
// concern, not its job).
it('collapses composite-key duplicates in the input', () => {
// Two identical `body` entries → deduped to one, then timestamp prepended.
const input = [BODY, BODY, ATTR_A];
const result = ensureLogsRequiredColumns(input);
expect(result.filter((c) => c.name === 'timestamp')).toHaveLength(1);
expect(result[0]).toStrictEqual(TIMESTAMP);
expect(result).toStrictEqual([TIMESTAMP, BODY, ATTR_A]);
expect(result.filter((c) => c.name === 'body')).toHaveLength(1);
});
it('keeps same-name fields with different contexts as distinct columns', () => {
// Different composite keys → both legitimate, neither deduped.
const ATTR_BODY: TelemetryFieldKey = {
name: 'body',
signal: 'logs',
fieldContext: 'attribute',
fieldDataType: 'string',
};
const input = [TIMESTAMP, BODY, ATTR_BODY];
expect(ensureLogsRequiredColumns(input)).toStrictEqual(input);
});
});

View File

@@ -2,6 +2,7 @@ import { TelemetryFieldKey } from 'api/v5/v5';
import { DataSource } from 'types/common/queryBuilder';
import { FontSize, OptionsQuery } from './types';
import { buildCompositeKey } from './utils';
export const URL_OPTIONS = 'options';
@@ -35,22 +36,48 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
},
];
export const LOGS_REQUIRED_COLUMNS = ['timestamp', 'body'] as const;
// Names that must always be present in logs selectColumns (writer invariant).
const LOGS_REQUIRED_COLUMN_NAMES = defaultLogsSelectedColumns.map(
(c) => c.name,
);
/**
* Always-on invariant: every logs selectColumns array must contain `body` and
* `timestamp`. Applied at both loader and writer boundaries so the picker, the
* table, and persisted state can never diverge into a "missing required
* column" state.
*/
// Composite keys (not bare names) so the picker locks ONLY the canonical
// `log.body`/`log.timestamp` — a same-name variant like `attribute.body` stays
// removable.
export const LOGS_REQUIRED_COLUMNS = defaultLogsSelectedColumns.map((c) =>
buildCompositeKey(c.name, c.fieldContext),
);
// Drop composite-key duplicates (never legitimate — they only come from
// corrupted state). Returns the same array reference when nothing to dedupe.
export function dedupeColumnsByCompositeKey(
columns: TelemetryFieldKey[],
): TelemetryFieldKey[] {
const seen = new Set<string>();
let hasDuplicate = false;
const deduped = columns.filter((c) => {
const key = buildCompositeKey(c.name, c.fieldContext);
if (seen.has(key)) {
hasDuplicate = true;
return false;
}
seen.add(key);
return true;
});
return hasDuplicate ? deduped : columns;
}
// Logs selectColumns invariant: no composite-key duplicates, and body +
// timestamp always present. Applied at loader + writer boundaries.
export function ensureLogsRequiredColumns(
columns: TelemetryFieldKey[],
): TelemetryFieldKey[] {
const missing = LOGS_REQUIRED_COLUMNS.filter(
(name) => !columns.some((c) => c.name === name),
const deduped = dedupeColumnsByCompositeKey(columns);
const missing = LOGS_REQUIRED_COLUMN_NAMES.filter(
(name) => !deduped.some((c) => c.name === name),
);
if (missing.length === 0) {
return columns;
return deduped;
}
const defaultsByName = new Map(
defaultLogsSelectedColumns.map((c) => [c.name, c]),
@@ -58,7 +85,7 @@ export function ensureLogsRequiredColumns(
const prepended = missing
.map((name) => defaultsByName.get(name))
.filter((c): c is TelemetryFieldKey => c !== undefined);
return [...prepended, ...columns];
return [...prepended, ...deduped];
}
export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [

View File

@@ -103,7 +103,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return {
...rest,
...(domainToAdminEmail && { domainToAdminEmail }),
domainToAdminEmail: domainToAdminEmail ?? {},
};
}, [form]);
@@ -129,7 +129,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return {
...rest,
...(groupMappings && { groupMappings }),
groupMappings: groupMappings ?? {},
};
}, [form]);

View File

@@ -0,0 +1,144 @@
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
import {
convertDomainMappingsToList,
convertDomainMappingsToRecord,
convertGroupMappingsToList,
convertGroupMappingsToRecord,
prepareInitialValues,
} from './CreateEdit.utils';
describe('convertGroupMappingsToRecord', () => {
it('returns undefined for an empty list', () => {
expect(convertGroupMappingsToRecord([])).toBeUndefined();
});
it('returns undefined when input is undefined', () => {
expect(convertGroupMappingsToRecord(undefined)).toBeUndefined();
});
it('converts entries to a Record', () => {
expect(
convertGroupMappingsToRecord([
{ groupName: 'admins', role: 'ADMIN' },
{ groupName: 'viewers', role: 'VIEWER' },
]),
).toStrictEqual({ admins: 'ADMIN', viewers: 'VIEWER' });
});
it('skips entries with missing groupName or role', () => {
expect(
convertGroupMappingsToRecord([
{ groupName: 'admins', role: 'ADMIN' },
{ groupName: '', role: 'VIEWER' },
{ role: 'EDITOR' },
]),
).toStrictEqual({ admins: 'ADMIN' });
});
});
describe('convertDomainMappingsToRecord', () => {
it('returns undefined for an empty list', () => {
expect(convertDomainMappingsToRecord([])).toBeUndefined();
});
it('returns undefined when input is undefined', () => {
expect(convertDomainMappingsToRecord(undefined)).toBeUndefined();
});
it('converts entries to a Record', () => {
expect(
convertDomainMappingsToRecord([
{ domain: 'example.com', adminEmail: 'admin@example.com' },
{ domain: 'corp.io', adminEmail: 'it@corp.io' },
]),
).toStrictEqual({
'example.com': 'admin@example.com',
'corp.io': 'it@corp.io',
});
});
});
describe('round-trip fidelity', () => {
it('Record → list → Record preserves group mappings', () => {
const original = { admins: 'ADMIN', devs: 'EDITOR', viewers: 'VIEWER' };
expect(
convertGroupMappingsToRecord(convertGroupMappingsToList(original)),
).toStrictEqual(original);
});
it('Record → list → Record preserves domain mappings', () => {
const original = {
'example.com': 'admin@example.com',
'corp.io': 'it@corp.io',
};
expect(
convertDomainMappingsToRecord(convertDomainMappingsToList(original)),
).toStrictEqual(original);
});
});
describe('prepareInitialValues', () => {
it('returns empty defaults when no record is provided', () => {
expect(prepareInitialValues(undefined)).toStrictEqual({
name: '',
ssoEnabled: false,
ssoType: '',
});
});
it('hydrates groupMappings Record into groupMappingsList for the form', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: false,
groupMappings: { admins: 'ADMIN', viewers: 'VIEWER' },
},
},
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([
{ groupName: 'admins', role: 'ADMIN' },
{ groupName: 'viewers', role: 'VIEWER' },
]);
});
it('hydrates domainToAdminEmail Record into domainToAdminEmailList for the form', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
googleAuthConfig: {
clientId: 'id',
clientSecret: 'secret',
domainToAdminEmail: { 'example.com': 'admin@example.com' },
},
},
});
expect(result.googleAuthConfig?.domainToAdminEmailList).toStrictEqual([
{ domain: 'example.com', adminEmail: 'admin@example.com' },
]);
});
it('sets groupMappingsList to empty array when roleMapping has no groupMappings', () => {
const result = prepareInitialValues({
id: 'domain-1',
name: 'example.com',
config: {
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
roleMapping: { defaultRole: 'VIEWER', useRoleAttribute: true },
},
});
expect(result.roleMapping?.groupMappingsList).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,169 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockGoogleAuthWithWorkspaceGroups,
mockUpdateSuccessResponse,
} from './mocks';
// The real @signozhq/ui/button has internal effects that prevent form.validateFields()
// from resolving inside act(). Mirror the pattern from SSOEnforcementToggle.test.tsx
// which mocks @signozhq/ui/switch for the same reason.
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
describe('CreateEdit — save payload correctness', () => {
afterEach(() => {
server.resetHandlers();
});
it('sends groupMappings: {} when all group mappings are deleted', async () => {
let capturedPayload: unknown = null;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={jest.fn()}
/>,
);
// Open the Role Mapping collapse (Ant Design Collapse responds to click events)
fireEvent.click(screen.getByText(/role mapping \(advanced\)/i));
// Wait for the 3 group mapping rows to appear
await waitFor(() =>
expect(
screen.getAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(3),
);
// Delete each row; re-query after each removal
fireEvent.click(
screen.getAllByRole('button', { name: /remove mapping/i })[0],
);
await waitFor(() =>
expect(
screen.getAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(2),
);
fireEvent.click(
screen.getAllByRole('button', { name: /remove mapping/i })[0],
);
await waitFor(() =>
expect(
screen.getAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(1),
);
fireEvent.click(
screen.getAllByRole('button', { name: /remove mapping/i })[0],
);
await waitFor(() =>
expect(
screen.queryAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(0),
);
// Submit — MSW intercepts the PUT request
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => expect(capturedPayload).not.toBeNull());
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
roleMapping: expect.objectContaining({ groupMappings: {} }),
}),
});
});
it('sends domainToAdminEmail: {} when all domain mappings are deleted', async () => {
let capturedPayload: unknown = null;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthWithWorkspaceGroups}
onClose={jest.fn()}
/>,
);
// Open the Google Workspace Groups collapse
fireEvent.click(screen.getByText(/google workspace groups/i));
// Wait for the single domain mapping row
await waitFor(() =>
expect(
screen.getByRole('button', { name: /remove mapping/i }),
).toBeInTheDocument(),
);
// Delete the row
fireEvent.click(screen.getByRole('button', { name: /remove mapping/i }));
await waitFor(() =>
expect(
screen.queryAllByRole('button', { name: /remove mapping/i }),
).toHaveLength(0),
);
// Submit
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => expect(capturedPayload).not.toBeNull());
expect(capturedPayload).toMatchObject({
config: expect.objectContaining({
googleAuthConfig: expect.objectContaining({
domainToAdminEmail: {},
}),
}),
});
});
});

View File

@@ -1,7 +1,6 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import GridTableComponent from 'container/GridTableComponent';
import { GRID_TABLE_CONFIG } from 'container/GridTableComponent/config';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { PanelWrapperProps } from './panelWrapper.types';
@@ -20,7 +19,7 @@ function TablePanelWrapper({
(queryResponse.data?.payload?.data?.result?.[0] as any)?.table || [];
const { thresholds } = widget;
const queryRangeRequest = queryResponse.data?.params as QueryRangeRequestV5;
const queryRangeRequest = queryResponse.data?.params;
return (
<GridTableComponent

View File

@@ -72,8 +72,20 @@
.alert-rule-scope {
margin-bottom: 12px;
.ant-radio-wrapper {
color: var(--l1-foreground);
// `.createForm label` styles field labels (font-weight 500, 14px,
// 6px bottom padding). Those bleed into the @signozhq/ui RadioGroup
// option labels, making them bold and vertically misaligned with the
// radio control. Reset them back to plain option-text styling.
label {
padding: 0;
font-weight: 400;
line-height: normal;
}
// Loosen the design-system default (grid gap 0.5rem) between options.
.silence-alerts-radio-group {
margin-top: 8px;
gap: 12px;
}
}
@@ -144,10 +156,7 @@
display: flex;
align-items: center;
gap: 8px;
.ant-btn {
margin-top: 8px;
}
margin-top: 12px;
}
.schedule-created-at {

View File

@@ -54,8 +54,7 @@ import {
} from './PlannedDowntimeutils';
import './PlannedDowntime.styles.scss';
import { RadioGroupItem } from '@signozhq/ui/radio-group';
import { RadioGroup } from '@signozhq/ui/radio-group';
import { RadioGroupItem, RadioGroup } from '@signozhq/ui/radio-group';
dayjs.locale('en');
dayjs.extend(utc);
@@ -471,7 +470,7 @@ export function PlannedDowntimeForm(
initialValue="specific"
className="alert-rule-scope"
>
<RadioGroup>
<RadioGroup className="silence-alerts-radio-group">
<RadioGroupItem value="all">All alert rules</RadioGroupItem>
<RadioGroupItem value="specific">Specific alert rules</RadioGroupItem>
</RadioGroup>

View File

@@ -2,9 +2,8 @@ import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useDashboardVarConfig from 'container/QueryTable/Drilldown/useDashboardVarConfig';
import { SuccessResponse } from 'types/api';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getTimeRange } from 'utils/getTimeRange';
@@ -44,10 +43,7 @@ const useAggregateDrilldown = ({
aggregateData: AggregateData | null;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
queryRange?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryRange?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
}): {
aggregateDrilldownConfig: {
header?: string | React.ReactNode;

View File

@@ -2,9 +2,8 @@ import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { SuccessResponse } from 'types/api';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { isValidQueryName } from './drilldownUtils';
@@ -20,10 +19,7 @@ interface UseGraphContextMenuProps {
setSubMenu: (subMenu: string) => void;
contextLinks?: ContextLinksData;
panelType?: PANEL_TYPES;
queryRange?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
queryRange?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>;
}
export function useGraphContextMenu({

View File

@@ -35,10 +35,7 @@
display: flex;
align-items: center;
gap: 8px;
.ant-btn {
margin-top: 8px;
}
margin-top: 12px;
}
.routing-policies-table {

View File

@@ -8,7 +8,7 @@
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0;
@@ -34,9 +34,9 @@
}
.refresh-interval-text {
padding: 12px 14px 8px 14px;
padding: 12px 14px 8px 14px !important;
color: var(--muted-foreground);
font-size: 11px;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */

View File

@@ -7,8 +7,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { AppState } from 'store/reducers';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -19,16 +18,13 @@ export const useGetExplorerQueryRange = (
requestData: Query | null,
panelType: PANEL_TYPES | null,
version: string,
options?: UseQueryOptions<SuccessResponse<MetricRangePayloadProps>, Error>,
options?: UseQueryOptions<MetricQueryRangeSuccessResponse, Error>,
params?: Record<string, unknown>,
isDependentOnQB = true,
keyRef?: MutableRefObject<any>,
headers?: Record<string, string>,
selectedTimeInterval?: GetQueryResultsProps['globalSelectedInterval'],
): UseQueryResult<
SuccessResponse<MetricRangePayloadProps> & { warning?: Warning },
Error
> => {
): UseQueryResult<MetricQueryRangeSuccessResponse, Error> => {
const { isEnabledQuery } = useQueryBuilder();
const {
selectedTime: globalSelectedInterval,

View File

@@ -9,7 +9,7 @@
}
.ant-tabs-nav {
padding: 0 8px;
padding-left: 16px;
margin-bottom: 0px;
&::before {

View File

@@ -8,7 +8,7 @@ import {
IClickHouseQuery,
IPromQLQuery,
} from '../queryBuilder/queryBuilderData';
import { ExecStats } from '../v5/queryRange';
import { ExecStats, QueryRangeRequestV5 } from '../v5/queryRange';
import { QueryData, QueryDataV3 } from '../widgets/getQuery';
export type QueryRangePayload = {
@@ -39,11 +39,16 @@ export interface MetricRangePayloadProps {
meta?: ExecStats;
}
/** Query range success response including optional warning and meta */
export type MetricQueryRangeSuccessResponse = SuccessResponse<
/** Query range success response. `params` is the request that produced the
* payload; `warning` and `meta` are lifted from the payload to the top level
* by `getQueryResults.ts` for consumer convenience. */
export interface MetricQueryRangeSuccessResponse extends SuccessResponse<
MetricRangePayloadProps,
unknown
> & { warning?: Warning; meta?: ExecStats };
QueryRangeRequestV5
> {
warning?: Warning;
meta?: ExecStats;
}
export interface MetricRangePayloadV3 {
data: {

View File

@@ -1,8 +1,7 @@
import { UseQueryResult } from 'react-query';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import store from 'store';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
export const getTimeRangeFromQueryRangeRequest = (
@@ -28,13 +27,9 @@ export const getTimeRangeFromQueryRangeRequest = (
};
export const getTimeRange = (
widgetQueryRange?: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>,
widgetQueryRange?: UseQueryResult<MetricQueryRangeSuccessResponse, Error>,
): Record<string, number> => {
const widgetParams =
(widgetQueryRange?.data?.params as QueryRangeRequestV5) || null;
const widgetParams = widgetQueryRange?.data?.params;
return getTimeRangeFromQueryRangeRequest(widgetParams);
};

View File

@@ -7,6 +7,12 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
)
type SkipResourceFingerprint struct {
Enabled bool `yaml:"enabled" mapstructure:"enabled"`
// If count of fingerprint is above threshold, skip the fingerprint subquery and filter on main table instead.
Threshold uint64 `yaml:"threshold" mapstructure:"threshold"`
}
// Config represents the configuration for the querier.
type Config struct {
// CacheTTL is the TTL for cached query results
@@ -15,6 +21,8 @@ type Config struct {
FluxInterval time.Duration `yaml:"flux_interval" mapstructure:"flux_interval"`
// MaxConcurrentQueries is the maximum number of concurrent queries for missing ranges
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
// SkipResourceFingerprint configures when the resource fingerprint subquery is skipped in favor of main-table filtering.
SkipResourceFingerprint SkipResourceFingerprint `yaml:"skip_resource_fingerprint" mapstructure:"skip_resource_fingerprint"`
}
// NewConfigFactory creates a new config factory for querier.
@@ -28,6 +36,10 @@ func newConfig() factory.Config {
CacheTTL: 168 * time.Hour,
FluxInterval: 5 * time.Minute,
MaxConcurrentQueries: 4,
SkipResourceFingerprint: SkipResourceFingerprint{
Enabled: false,
Threshold: 100000,
},
}
}
@@ -42,6 +54,9 @@ func (c Config) Validate() error {
if c.MaxConcurrentQueries <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_concurrent_queries must be positive, got %v", c.MaxConcurrentQueries)
}
if c.SkipResourceFingerprint.Enabled && c.SkipResourceFingerprint.Threshold <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "skip_resource_fingerprint.threshold must be > 0 when enabled")
}
return nil
}

View File

@@ -88,6 +88,8 @@ func newProvider(
traceAggExprRewriter,
telemetryStore,
flagger,
cfg.SkipResourceFingerprint.Enabled,
cfg.SkipResourceFingerprint.Threshold,
)
// Create trace operator statement builder
@@ -121,6 +123,9 @@ func newProvider(
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
flagger,
telemetryStore,
cfg.SkipResourceFingerprint.Enabled,
cfg.SkipResourceFingerprint.Threshold,
)
// Create audit statement builder

View File

@@ -89,6 +89,9 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
telemetrylogs.DefaultFullTextColumn,
telemetrylogs.GetBodyJSONKey,
fl,
nil,
false,
100000,
)
return querier.New(
@@ -134,6 +137,8 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
traceAggExprRewriter,
telemetryStore,
fl,
false,
100000,
)
return querier.New(

View File

@@ -1205,6 +1205,9 @@ func buildJSONTestStatementBuilder(t *testing.T, addIndexes bool) (*logQueryStat
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
return statementBuilder, mockMetadataStore

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryresourcefilter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -19,13 +20,14 @@ import (
)
type logQueryStatementBuilder struct {
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fl flagger.Flagger
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
resourceFilterResolver *telemetryresourcefilter.ResourceFingerprintResolver[qbtypes.LogAggregation]
aggExprRewriter qbtypes.AggExprRewriter
fl flagger.Flagger
skipResourceFingerprintEnabled bool
fullTextColumn *telemetrytypes.TelemetryFieldKey
jsonKeyToKey qbtypes.JsonKeyToFieldFunc
@@ -42,10 +44,13 @@ func NewLogQueryStatementBuilder(
fullTextColumn *telemetrytypes.TelemetryFieldKey,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
fl flagger.Flagger,
telemetryStore telemetrystore.TelemetryStore,
skipResourceFingerprintEnable bool,
skipResourceFingerprintThreshold uint64,
) *logQueryStatementBuilder {
logsSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrylogs")
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.LogAggregation](
resourceFilterResolver := telemetryresourcefilter.NewResolver[qbtypes.LogAggregation](
settings,
DBName,
LogsResourceV2TableName,
@@ -55,18 +60,21 @@ func NewLogQueryStatementBuilder(
fullTextColumn,
jsonKeyToKey,
fl,
telemetryStore,
skipResourceFingerprintThreshold,
)
return &logQueryStatementBuilder{
logger: logsSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
aggExprRewriter: aggExprRewriter,
fl: fl,
fullTextColumn: fullTextColumn,
jsonKeyToKey: jsonKeyToKey,
logger: logsSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
resourceFilterResolver: resourceFilterResolver,
aggExprRewriter: aggExprRewriter,
fl: fl,
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
fullTextColumn: fullTextColumn,
jsonKeyToKey: jsonKeyToKey,
}
}
@@ -271,9 +279,11 @@ func (b *logQueryStatementBuilder) buildListQuery(
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" {
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -315,7 +325,7 @@ func (b *logQueryStatementBuilder) buildListQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -373,9 +383,11 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" {
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -419,7 +431,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Add FROM clause
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -531,9 +543,11 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
bodyJSONEnabled = b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" && !skipResourceCTE {
}
if frag != "" && !skipResourceCTE {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -576,7 +590,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, LogsV2TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
@@ -640,6 +654,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
skipResourceFilter bool,
) (querybuilder.PreparedWhereClause, error) {
var preparedWhereClause querybuilder.PreparedWhereClause
@@ -656,7 +671,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
ConditionBuilder: b.cb,
FieldKeys: keys,
BodyJSONEnabled: bodyJSONEnabled,
SkipResourceFilter: true,
SkipResourceFilter: skipResourceFilter,
FullTextColumn: b.fullTextColumn,
JsonKeyToKey: b.jsonKeyToKey,
Variables: variables,
@@ -701,39 +716,52 @@ func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.LogAggreg
return 0, false
}
// maybeAttachResourceFilter decides whether to pre-filter on the resource table.
//
// The resource table maps resource attributes (e.g. service.name) to fingerprints.
// When it helps, we look up the matching fingerprints there first and feed them to the
// main query as a CTE, so the main table only scans those fingerprints.
//
// There are three outcomes:
//
// 1. Skip it — the filter has nothing we can pre-resolve. This happens when the filter
// is empty,no resource filter, or when its resource conditions sit under an OR (e.g.
// `name='GET' OR service.name='abc'`), because then we can't reduce to a fixed set
// of fingerprints. The main query filters on resource attributes inline instead.
// 2. Skip it — too many fingerprints match (over the configured threshold), so the CTE
// would not be selective enough to be worth it. Again, filter inline on the main table.
// 3. Use it — attach the matching fingerprints as the __resource_filter CTE and join
// the main table on resource_fingerprint.
func (b *logQueryStatementBuilder) maybeAttachResourceFilter(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (cteSQL string, cteArgs []any, err error) {
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
stmt, err := b.buildResourceFilterCTE(ctx, query, start, end, variables)
if b.skipResourceFingerprintEnabled {
decision, err := b.resourceFilterResolver.Resolve(ctx, query, start, end, variables)
if err != nil {
return "", nil, true, err
}
switch decision {
case qbtypes.ResourceFilterResolveKindNoOp:
return "", nil, true, nil
case qbtypes.ResourceFilterResolveKindFallback:
return "", nil, false, nil
}
}
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
ctx, start, end, qbtypes.RequestTypeRaw, query, variables,
)
if err != nil {
return "", nil, err
return "", nil, true, err
}
if stmt == nil {
return "", nil, nil
return "", nil, true, nil
}
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
}
func (b *logQueryStatementBuilder) buildResourceFilterCTE(
ctx context.Context,
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
return b.resourceFilterStmtBuilder.Build(
ctx,
start,
end,
qbtypes.RequestTypeRaw,
query,
variables,
)
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
}

View File

@@ -2,19 +2,36 @@ package telemetrylogs
import (
"context"
"regexp"
"testing"
"time"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/require"
)
type regexQueryMatcher struct{}
func (m *regexQueryMatcher) Match(expectedSQL, actualSQL string) error {
re, err := regexp.Compile(expectedSQL)
if err != nil {
return err
}
if !re.MatchString(actualSQL) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected query to match %s, got %s", expectedSQL, actualSQL)
}
return nil
}
func TestStatementBuilderTimeSeries(t *testing.T) {
// Create a test release time
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
@@ -212,6 +229,9 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -353,6 +373,9 @@ func TestStatementBuilderListQuery(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -499,6 +522,9 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -575,6 +601,9 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -670,6 +699,9 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -894,6 +926,9 @@ func TestAdjustKey(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
for _, c := range cases {
@@ -1039,6 +1074,9 @@ func TestStmtBuilderBodyField(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
@@ -1138,6 +1176,9 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
nil,
false,
100000,
)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
@@ -1157,3 +1198,110 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
})
}
}
// TestSkipResourceFingerprintLogs exercises the three resolver outcomes for
// logs: use-CTE (count < threshold), fallback (count >= threshold), and the
// legacy path (feature disabled).
func TestSkipResourceFingerprintLogs(t *testing.T) {
const (
startMs = uint64(1747947419000)
endMs = uint64(1747983448000)
threshold = uint64(10)
)
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "service.name = 'redis-manual'",
},
Limit: 5,
}
t.Run("disabled uses the legacy CTE", func(t *testing.T) {
sb := newSkipResourceFingerprintLogsBuilder(t, nil, false, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
})
t.Run("CTE attached when count below threshold", func(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_logs\.distributed_logs_v2_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{uint64(2)}))
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
require.NoError(t, mock.ExpectationsWereMet())
})
t.Run("fallback when count at or above threshold", func(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_logs\.distributed_logs_v2_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{threshold}))
sb := newSkipResourceFingerprintLogsBuilder(t, mockStore, true, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.NotContains(t, stmt.Query, "__resource_filter AS")
require.NotContains(t, stmt.Query, "resource_fingerprint")
require.Contains(t, stmt.Query, "service.name")
require.NoError(t, mock.ExpectationsWereMet())
})
}
func newSkipResourceFingerprintLogsBuilder(
t *testing.T,
telemetryStore telemetrystore.TelemetryStore,
skipEnable bool,
threshold uint64,
) *logQueryStatementBuilder {
t.Helper()
fl := flaggertest.New(t)
fm := NewFieldMapper(fl)
cb := NewConditionBuilder(fm, fl)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
aggExprRewriter := querybuilder.NewAggExprRewriter(
instrumentationtest.New().ToProviderSettings(),
DefaultFullTextColumn,
fm,
cb,
GetBodyJSONKey,
fl,
)
return NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
DefaultFullTextColumn,
GetBodyJSONKey,
fl,
telemetryStore,
skipEnable,
threshold,
)
}

View File

@@ -0,0 +1,77 @@
package telemetryresourcefilter
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/telemetrystore"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type ResourceFingerprintResolver[T any] struct {
stmtBuilder *resourceFilterStatementBuilder[T]
telemetryStore telemetrystore.TelemetryStore
threshold uint64
}
func NewResolver[T any](
settings factory.ProviderSettings,
dbName string,
tableName string,
signal telemetrytypes.Signal,
source telemetrytypes.Source,
metadataStore telemetrytypes.MetadataStore,
fullTextColumn *telemetrytypes.TelemetryFieldKey,
jsonKeyToKey qbtypes.JsonKeyToFieldFunc,
fl flagger.Flagger,
telemetryStore telemetrystore.TelemetryStore,
threshold uint64,
) *ResourceFingerprintResolver[T] {
return &ResourceFingerprintResolver[T]{
stmtBuilder: New[T](
settings,
dbName,
tableName,
signal,
source,
metadataStore,
fullTextColumn,
jsonKeyToKey,
fl,
),
telemetryStore: telemetryStore,
threshold: threshold,
}
}
func (r *ResourceFingerprintResolver[T]) StatementBuilder() qbtypes.StatementBuilder[T] {
return r.stmtBuilder
}
func (r *ResourceFingerprintResolver[T]) Resolve(
ctx context.Context,
query qbtypes.QueryBuilderQuery[T],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (qbtypes.ResourceFilterResolveKind, error) {
countStmt, err := r.stmtBuilder.BuildCount(ctx, start, end, query, variables)
if err != nil {
return qbtypes.ResourceFilterResolveKindNoOp, err
}
if countStmt == nil {
return qbtypes.ResourceFilterResolveKindNoOp, nil
}
var count uint64
row := r.telemetryStore.ClickhouseDB().QueryRow(ctx, countStmt.Query, countStmt.Args...)
if err := row.Scan(&count); err != nil {
return qbtypes.ResourceFilterResolveKindNoOp, err
}
if count >= r.threshold {
return qbtypes.ResourceFilterResolveKindFallback, nil
}
return qbtypes.ResourceFilterResolveKindUseCTE, nil
}

View File

@@ -98,17 +98,7 @@ func (b *resourceFilterStatementBuilder[T]) Build(
query qbtypes.QueryBuilderQuery[T],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
q := sqlbuilder.NewSelectBuilder()
q.Select("fingerprint")
q.From(fmt.Sprintf("%s.%s", b.dbName, b.tableName))
keySelectors := b.getKeySelectors(query)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, err
}
isNoOp, err := b.addConditions(ctx, q, start, end, query, keys, variables)
q, isNoOp, err := b.buildQuery(ctx, start, end, "fingerprint", query, variables)
if err != nil {
return nil, err
}
@@ -127,6 +117,59 @@ func (b *resourceFilterStatementBuilder[T]) Build(
}, nil
}
// buildQuery selects selectExpr from the resource table and applies the filter and
// time conditions. isNoOp is true when the filter resolves to no resource conditions.
func (b *resourceFilterStatementBuilder[T]) buildQuery(
ctx context.Context,
start, end uint64,
selectExpr string,
query qbtypes.QueryBuilderQuery[T],
variables map[string]qbtypes.VariableItem,
) (*sqlbuilder.SelectBuilder, bool, error) {
q := sqlbuilder.NewSelectBuilder()
q.Select(selectExpr)
q.From(fmt.Sprintf("%s.%s", b.dbName, b.tableName))
keySelectors := b.getKeySelectors(query)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
return nil, false, err
}
isNoOp, err := b.addConditions(ctx, q, start, end, query, keys, variables)
if err != nil {
return nil, false, err
}
return q, isNoOp, nil
}
// BuildCount returns a statement that counts the distinct fingerprints matching
// the resource filter. Returns (nil, nil) when the filter is a no-op.
//
// It uses uniq() rather than count() over a GROUP BY: uniq's approximation is well
// within tolerance for a threshold check and is ~2x faster with far less memory.
func (b *resourceFilterStatementBuilder[T]) BuildCount(
ctx context.Context,
start uint64,
end uint64,
query qbtypes.QueryBuilderQuery[T],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
q, isNoOp, err := b.buildQuery(ctx, start, end, "uniq(fingerprint)", query, variables)
if err != nil {
return nil, err
}
if isNoOp {
return nil, nil //nolint:nilnil
}
stmt, args := q.BuildWithFlavor(sqlbuilder.ClickHouse)
return &qbtypes.Statement{
Query: stmt,
Args: args,
}, nil
}
// addConditions adds both filter and time conditions to the query.
// Returns true (isNoOp) when the filter expression evaluated to no resource conditions,
// meaning the CTE would select all fingerprints and should be skipped entirely.

View File

@@ -24,13 +24,13 @@ var (
)
type traceQueryStatementBuilder struct {
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
resourceFilterStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
aggExprRewriter qbtypes.AggExprRewriter
telemetryStore telemetrystore.TelemetryStore
logger *slog.Logger
metadataStore telemetrytypes.MetadataStore
fm qbtypes.FieldMapper
cb qbtypes.ConditionBuilder
resourceFilterResolver *telemetryresourcefilter.ResourceFingerprintResolver[qbtypes.TraceAggregation]
aggExprRewriter qbtypes.AggExprRewriter
skipResourceFingerprintEnabled bool
}
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
@@ -43,10 +43,12 @@ func NewTraceQueryStatementBuilder(
aggExprRewriter qbtypes.AggExprRewriter,
telemetryStore telemetrystore.TelemetryStore,
flagger flagger.Flagger,
skipResourceFingerprintEnable bool,
skipResourceFingerprintThreshold uint64,
) *traceQueryStatementBuilder {
tracesSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/telemetrytraces")
resourceFilterStmtBuilder := telemetryresourcefilter.New[qbtypes.TraceAggregation](
resourceFilterResolver := telemetryresourcefilter.NewResolver[qbtypes.TraceAggregation](
settings,
DBName,
TracesResourceV3TableName,
@@ -56,16 +58,18 @@ func NewTraceQueryStatementBuilder(
nil,
nil,
flagger,
telemetryStore,
skipResourceFingerprintThreshold,
)
return &traceQueryStatementBuilder{
logger: tracesSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
resourceFilterStmtBuilder: resourceFilterStmtBuilder,
aggExprRewriter: aggExprRewriter,
telemetryStore: telemetryStore,
logger: tracesSettings.Logger(),
metadataStore: metadataStore,
fm: fieldMapper,
cb: conditionBuilder,
resourceFilterResolver: resourceFilterResolver,
aggExprRewriter: aggExprRewriter,
skipResourceFingerprintEnabled: skipResourceFingerprintEnable,
}
}
@@ -308,9 +312,11 @@ func (b *traceQueryStatementBuilder) buildListQuery(
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" {
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -328,7 +334,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -389,15 +395,17 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, distSB, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, distSB, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" {
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -498,9 +506,11 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" {
}
if frag != "" {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -541,7 +551,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
}
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -650,9 +660,11 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
cteArgs [][]any
)
if frag, args, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables); err != nil {
frag, args, skipResourceFilter, err := b.maybeAttachResourceFilter(ctx, sb, query, start, end, variables)
if err != nil {
return nil, err
} else if frag != "" && !skipResourceCTE {
}
if frag != "" && !skipResourceCTE {
cteFragments = append(cteFragments, frag)
cteArgs = append(cteArgs, args)
}
@@ -694,7 +706,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -757,6 +769,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
skipResourceFilter bool,
) (querybuilder.PreparedWhereClause, error) {
var preparedWhereClause querybuilder.PreparedWhereClause
@@ -770,7 +783,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
FieldMapper: b.fm,
ConditionBuilder: b.cb,
FieldKeys: keys,
SkipResourceFilter: true,
SkipResourceFilter: skipResourceFilter,
Variables: variables,
StartNs: start,
EndNs: end,
@@ -805,40 +818,52 @@ func aggOrderBy(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggr
return 0, false
}
// maybeAttachResourceFilter decides whether to pre-filter on the resource table.
//
// The resource table maps resource attributes (e.g. service.name) to fingerprints.
// When it helps, we look up the matching fingerprints there first and feed them to the
// main query as a CTE, so the main table only scans those fingerprints.
//
// There are three outcomes:
//
// 1. Skip it — the filter has nothing we can pre-resolve. This happens when the filter
// is empty,no resource filter, or when its resource conditions sit under an OR (e.g.
// `name='GET' OR service.name='abc'`), because then we can't reduce to a fixed set
// of fingerprints. The main query filters on resource attributes inline instead.
// 2. Skip it — too many fingerprints match (over the configured threshold), so the CTE
// would not be selective enough to be worth it. Again, filter inline on the main table.
// 3. Use it — attach the matching fingerprints as the __resource_filter CTE and join
// the main table on resource_fingerprint.
func (b *traceQueryStatementBuilder) maybeAttachResourceFilter(
ctx context.Context,
sb *sqlbuilder.SelectBuilder,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (cteSQL string, cteArgs []any, err error) {
) (cteSQL string, cteArgs []any, skipResourceFilter bool, err error) {
stmt, err := b.buildResourceFilterCTE(ctx, query, start, end, variables)
if b.skipResourceFingerprintEnabled {
decision, err := b.resourceFilterResolver.Resolve(ctx, query, start, end, variables)
if err != nil {
return "", nil, true, err
}
switch decision {
case qbtypes.ResourceFilterResolveKindNoOp:
return "", nil, true, nil
case qbtypes.ResourceFilterResolveKindFallback:
return "", nil, false, nil
}
}
stmt, err := b.resourceFilterResolver.StatementBuilder().Build(
ctx, start, end, qbtypes.RequestTypeRaw, query, variables,
)
if err != nil {
return "", nil, err
return "", nil, true, err
}
if stmt == nil {
return "", nil, nil
return "", nil, true, nil
}
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, nil
}
func (b *traceQueryStatementBuilder) buildResourceFilterCTE(
ctx context.Context,
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
start, end uint64,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
return b.resourceFilterStmtBuilder.Build(
ctx,
start,
end,
qbtypes.RequestTypeRaw,
query,
variables,
)
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
}

View File

@@ -2,19 +2,36 @@ package telemetrytraces
import (
"context"
"regexp"
"testing"
"time"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/require"
)
type regexQueryMatcher struct{}
func (m *regexQueryMatcher) Match(expectedSQL, actualSQL string) error {
re, err := regexp.Compile(expectedSQL)
if err != nil {
return err
}
if !re.MatchString(actualSQL) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "expected query to match %s, got %s", expectedSQL, actualSQL)
}
return nil
}
func TestStatementBuilder(t *testing.T) {
// releaseTime is chosen so it lands inside the standard [1747947419000, 1747983448000]ms
// test window, keeping the multiIf SQL form for resource fields.
@@ -370,6 +387,8 @@ func TestStatementBuilder(t *testing.T) {
aggExprRewriter,
nil,
fl,
false,
100000,
)
vars := map[string]qbtypes.VariableItem{
@@ -666,6 +685,8 @@ func TestStatementBuilderListQuery(t *testing.T) {
aggExprRewriter,
nil,
fl,
false,
100000,
)
for _, c := range cases {
@@ -794,6 +815,8 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
aggExprRewriter,
nil,
fl,
false,
100000,
)
q, err := statementBuilder.Build(context.Background(), 1747947419000, 1747983448000, c.requestType, c.query, nil)
@@ -864,6 +887,8 @@ func TestStatementBuilderGroupByResourceEvolution(t *testing.T) {
aggExprRewriter,
nil,
fl,
false,
100000,
)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
@@ -1029,6 +1054,8 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
aggExprRewriter,
nil,
fl,
false,
100000,
)
for _, c := range cases {
@@ -1561,3 +1588,115 @@ func TestAdjustKeys(t *testing.T) {
})
}
}
// TestSkipResourceFingerprint exercises the three resolver outcomes when
// skip_resource_fingerprint is enabled: use-CTE (count < threshold),
// fallback (count >= threshold), and the legacy path (feature disabled).
func TestSkipResourceFingerprint(t *testing.T) {
const (
startMs = uint64(1747947419000)
endMs = uint64(1747983448000)
threshold = uint64(10)
)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{
Expression: "service.name = 'redis-manual'",
},
SelectFields: []telemetrytypes.TelemetryFieldKey{
{
Name: "name",
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
Limit: 5,
}
t.Run("disabled uses the legacy CTE", func(t *testing.T) {
sb := newSkipResourceFingerprintBuilder(t, nil, false, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
})
t.Run("CTE attached when count below threshold", func(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
// Only the count query runs against the telemetry store; the CTE
// itself is embedded as SQL in the main query (no extra round trip).
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_traces\.distributed_traces_v3_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{uint64(2)}))
sb := newSkipResourceFingerprintBuilder(t, mockStore, true, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.Contains(t, stmt.Query, "__resource_filter AS (SELECT fingerprint")
require.Contains(t, stmt.Query, "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
require.NoError(t, mock.ExpectationsWereMet())
})
t.Run("fallback when count at or above threshold", func(t *testing.T) {
mockStore := telemetrystoretest.New(telemetrystore.Config{}, &regexQueryMatcher{})
mock := mockStore.Mock()
mock.ExpectQueryRow(`SELECT uniq\(fingerprint\) FROM signoz_traces\.distributed_traces_v3_resource`).
WillReturnRow(cmock.NewRow([]cmock.ColumnType{
{Name: "count", Type: "UInt64"},
}, []any{threshold}))
sb := newSkipResourceFingerprintBuilder(t, mockStore, true, threshold)
stmt, err := sb.Build(context.Background(), startMs, endMs, qbtypes.RequestTypeRaw, query, nil)
require.NoError(t, err)
require.NotContains(t, stmt.Query, "__resource_filter AS")
require.NotContains(t, stmt.Query, "resource_fingerprint")
// resource conditions are pushed onto the main table via the
// resource.`service.name` / resources_string lookup
require.Contains(t, stmt.Query, "service.name")
require.NoError(t, mock.ExpectationsWereMet())
})
}
func newSkipResourceFingerprintBuilder(
t *testing.T,
telemetryStore telemetrystore.TelemetryStore,
skipEnable bool,
threshold uint64,
) *traceQueryStatementBuilder {
t.Helper()
fm := NewFieldMapper()
cb := NewConditionBuilder(fm)
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
mockMetadataStore.KeysMap = buildCompleteFieldKeyMap(releaseTime)
fl := flaggertest.New(t)
aggExprRewriter := querybuilder.NewAggExprRewriter(
instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl,
)
return NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
fm,
cb,
aggExprRewriter,
telemetryStore,
fl,
skipEnable,
threshold,
)
}

View File

@@ -25,7 +25,7 @@ func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementB
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, nil, fl)
traceStmtBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl,
mockMetadataStore, fm, cb, aggExprRewriter, nil, fl, false, 100000,
)
return NewTraceOperatorStatementBuilder(
instrumentationtest.New().ToProviderSettings(),

View File

@@ -6,13 +6,13 @@ import (
"testing"
"time"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/stretchr/testify/require"
)
@@ -46,8 +46,10 @@ func TestTraceTimeRangeOptimization(t *testing.T) {
fm,
cb,
aggExprRewriter,
nil, // telemetryStore is nil - optimization won't happen but code path is tested
nil, // telemetryStore is nil - adaptive path is disabled
fl,
false,
100000,
)
tests := []struct {

View File

@@ -0,0 +1,9 @@
package querybuildertypesv5
type ResourceFilterResolveKind int
const (
ResourceFilterResolveKindNoOp ResourceFilterResolveKind = iota
ResourceFilterResolveKindUseCTE
ResourceFilterResolveKindFallback
)

View File

@@ -0,0 +1,344 @@
"""
Transparency check for the skip_resource_fingerprint optimization (traces and logs).
The optimization changes how a query's resource conditions are resolved depending on
how selective they are, but must change only ClickHouse performance, never the rows.
Each test runs the same query against the primary instance (optimization on,
threshold=2) and `signoz_fingerprint` (optimization off) and asserts the responses
are identical, covering all three resolver outcomes:
- CTE: a filter matching fewer fingerprints than the threshold resolves through the
fingerprint CTE.
- Fallback: a filter matching at or above the threshold pushes resource conditions
onto the main spans/logs table instead.
- No-op: a filter with no resource conditions to pre-resolve (no resource field, or
resource fields only under an OR) filters inline on the main table.
"""
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
BuilderQuery,
OrderBy,
TelemetryFieldKey,
assert_identical_query_response,
get_rows,
make_query_request,
)
from fixtures.traces import Traces
def test_skip_resource_fingerprint_traces_fallback_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A >= 2-fingerprint filter drives the fallback path; rows must match the fingerprint baseline."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# 3 distinct services share one env (3 fingerprints > threshold 2 -> fallback);
# the 4th has a different env and must be excluded.
env = {"deployment.environment": "skip-fallback"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-fb-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-fb-svc-b", **env}),
Traces(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-fb-svc-c", **env}),
Traces(timestamp=now - timedelta(seconds=7), resources={"service.name": "skip-fb-other", "deployment.environment": "skip-other"}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="traces",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="deployment.environment = 'skip-fallback'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 3
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_traces_cte_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A < 2-fingerprint filter resolves through the fingerprint CTE; rows must match the baseline."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# One service shares the env (1 fingerprint < threshold 2 -> CTE); two spans on it
# exercise dedup. A second service in a different env must be excluded.
env = {"deployment.environment": "skip-cte"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-cte-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-cte-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-cte-other", "deployment.environment": "skip-cte-other-env"}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="traces",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="deployment.environment = 'skip-cte'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_traces_or_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: 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 condition under an OR has no fixed fingerprint set, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# `name = ... OR service.name = ...` can't reduce to a fingerprint set (no-op path).
# span-1 matches on name, span-2 on service.name, span-3 matches neither.
env = {"deployment.environment": "skip-or"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), name="tr-or-name", resources={"service.name": "tr-or-svc-x", **env}),
Traces(timestamp=now - timedelta(seconds=9), name="tr-or-other", resources={"service.name": "tr-or-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=8), name="tr-or-other", resources={"service.name": "tr-or-svc-b", **env}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="traces",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="name = 'tr-or-name' OR service.name = 'tr-or-svc-a'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_traces_no_resource_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""A filter with no resource field has nothing to pre-resolve, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# Filtering only on the span name (an intrinsic, non-resource field) is a no-op for
# the resolver; span-1 matches, span-2 does not.
env = {"deployment.environment": "skip-nr"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), name="tr-nr-name", resources={"service.name": "tr-nr-svc-a", **env}),
Traces(timestamp=now - timedelta(seconds=9), name="tr-nr-other", resources={"service.name": "tr-nr-svc-b", **env}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="traces",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="name = 'tr-nr-name'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 1
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_fallback_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A >= 2-fingerprint filter drives the fallback path; rows must match the fingerprint baseline."""
now = datetime.now(tz=UTC)
# 3 distinct services share one env (3 fingerprints > threshold 2 -> fallback);
# the 4th has a different env and must be excluded.
env = {"deployment.environment": "skip-logs-fallback"}
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-logs-fb-svc-a", **env}, body="a"),
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-logs-fb-svc-b", **env}, body="b"),
Logs(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-logs-fb-svc-c", **env}, body="c"),
Logs(timestamp=now - timedelta(seconds=7), resources={"service.name": "skip-logs-fb-other", "deployment.environment": "skip-logs-other"}, body="noise"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="logs",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="deployment.environment = 'skip-logs-fallback'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 3
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_cte_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A < 2-fingerprint filter resolves through the fingerprint CTE; rows must match the baseline."""
now = datetime.now(tz=UTC)
# One service shares the env (1 fingerprint < threshold 2 -> CTE); two logs on it
# exercise dedup. A second service in a different env must be excluded.
env = {"deployment.environment": "skip-logs-cte"}
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "skip-logs-cte-svc-a", **env}, body="a"),
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "skip-logs-cte-svc-a", **env}, body="b"),
Logs(timestamp=now - timedelta(seconds=8), resources={"service.name": "skip-logs-cte-other", "deployment.environment": "skip-logs-cte-other-env"}, body="noise"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="logs",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="deployment.environment = 'skip-logs-cte'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_or_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A resource condition under an OR has no fixed fingerprint set, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC)
# `test.marker = ... OR service.name = ...` can't reduce to a fingerprint set (no-op path).
# log-1 matches on the attribute, log-2 on service.name, log-3 matches neither.
env = {"deployment.environment": "skip-logs-or"}
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "logs-or-svc-x", **env}, attributes={"test.marker": "logs-or-hit"}, body="a"),
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "logs-or-svc-a", **env}, body="b"),
Logs(timestamp=now - timedelta(seconds=8), resources={"service.name": "logs-or-svc-b", **env}, body="noise"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="logs",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="test.marker = 'logs-or-hit' OR service.name = 'logs-or-svc-a'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 2
assert_identical_query_response(optimized, fingerprint)
def test_skip_resource_fingerprint_logs_no_resource_filter_matches_fingerprint(
signoz: types.SigNoz,
signoz_fingerprint: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A filter with no resource field has nothing to pre-resolve, so it filters inline; rows must match the baseline."""
now = datetime.now(tz=UTC)
# Filtering only on an attribute (a non-resource field) is a no-op for the resolver;
# log-1 matches, log-2 does not.
env = {"deployment.environment": "skip-logs-nr"}
insert_logs(
[
Logs(timestamp=now - timedelta(seconds=10), resources={"service.name": "logs-nr-svc-a", **env}, attributes={"test.marker": "logs-nr-hit"}, body="a"),
Logs(timestamp=now - timedelta(seconds=9), resources={"service.name": "logs-nr-svc-b", **env}, body="b"),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = BuilderQuery(
signal="logs",
limit=50,
order=[OrderBy(TelemetryFieldKey("timestamp"), "asc")],
filter_expression="test.marker = 'logs-nr-hit'",
select_fields=[TelemetryFieldKey("service.name", "string", "resource"), TelemetryFieldKey("body")],
).to_dict()
start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
optimized = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
fingerprint = make_query_request(signoz_fingerprint, token, start_ms=start_ms, end_ms=end_ms, request_type="raw", queries=[query])
assert len(get_rows(optimized)) == 1
assert_identical_query_response(optimized, fingerprint)

View File

@@ -0,0 +1,71 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_skip_resource_fingerprint(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped SigNoz instance with the skip_resource_fingerprint
optimization enabled and a low threshold so the fallback resolver path is
exercised (filters matching >= 2 fingerprints skip the fingerprint CTE).
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-skip-resource-fingerprint",
env_overrides={
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_ENABLED": True,
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_THRESHOLD": 2,
},
)
@pytest.fixture(name="signoz_fingerprint", scope="package")
def signoz_fingerprint(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
A second SigNoz instance with the optimization disabled, so every query
resolves resource filters through the fingerprint CTE (the path the primary
also takes below the threshold). It shares the same ClickHouse (telemetry)
and sqlstore (users) as the primary instance, which lets a single admin
token authenticate against both and lets the tests diff the optimized
result against this fingerprint baseline for the same query and data.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-skip-resource-fingerprint-baseline",
env_overrides={
"SIGNOZ_QUERIER_SKIP__RESOURCE__FINGERPRINT_ENABLED": False,
},
)