Compare commits

...

4 Commits

Author SHA1 Message Date
Tushar Vats
86ee9ab846 chore(openapi): regenerate specs for bucketOptions.field 2026-10-30 11:44:22 +05:30
Tushar Vats
a2154df336 feat(querier): heatmaps for the logs and traces signals
The bucketed field goes on bucketOptions rather than in the aggregation
expression: a log or a span carries many values where a metric carries
one, and the builder keeps asking for count().

The absent-value guard is unconditional rather than logs/traces only.
greatest(NULL, 1) is 1 in ClickHouse, so a row missing the field would
otherwise be counted in the lowest linear bucket.
2026-10-30 11:44:22 +05:30
Aditya Singh
03bdeb9ba4 feat(saved-views): turn on saved views and the bottom strip (#13125)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- This PR turns on saved views and the bottom strip for all users. The
`SAVED_VIEW_ENABLED` flag now always reads as on.
- The flag code stays in place. The cleanup in
https://github.com/SigNoz/events-pod/issues/77 removes it.
- The old saved views URLs now redirect to their explorer through the
existing old-routes redirects:
  - `/logs/saved-views` to `/logs/logs-explorer`
  - `/traces/saved-views` to `/traces-explorer`
  - `/metrics-explorer/views` to `/metrics-explorer/explorer`
  - `/meter/explorer/views` to `/meter/explorer`

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/57

#### Additional Information
- Merge this PR after #13022, #13028 and #13027. Without them, the
explorers show a placeholder views panel next to the old bottom bar.
- The flag also hides the Pylon chat bubble and the chat support
gateway, because Support moves into the bottom strip.
2026-10-19 13:52:25 +00:00
Aditya Singh
e4e58e2349 fix(explorer): stop reporting dropped order by to sentry (#13153)
#### Description
- The explorer order-by sanitizer sent a Sentry warning each time it
dropped an order-by column. Each new order made a new Sentry issue, and
each new issue paged.
- Dropping a stale order is expected. Since #13085, it happens when a
user switches from List to Time Series or Table.
- The sanitizer no longer reports to Sentry. The filtering is unchanged.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/180
2026-10-19 11:03:04 +00:00
25 changed files with 426 additions and 411 deletions

View File

@@ -8151,6 +8151,8 @@ components:
type: object
Querybuildertypesv5BucketOptionsLinear:
properties:
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
@@ -8161,6 +8163,8 @@ components:
type: object
Querybuildertypesv5BucketOptionsLog:
properties:
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:

View File

@@ -300,20 +300,17 @@ describe('PrivateRoute', () => {
);
});
it('should redirect /logs-save-views to /logs/saved-views', () => {
renderPrivateRoute({ initialRoute: '/logs-save-views' });
it.each([
['/logs-save-views', ROUTES.LOGS_EXPLORER],
['/traces-save-views', ROUTES.TRACES_EXPLORER],
[ROUTES.LOGS_SAVE_VIEWS, ROUTES.LOGS_EXPLORER],
[ROUTES.TRACES_SAVE_VIEWS, ROUTES.TRACES_EXPLORER],
[ROUTES.METRICS_EXPLORER_VIEWS, ROUTES.METRICS_EXPLORER_EXPLORER],
[ROUTES.METER_EXPLORER_VIEWS, ROUTES.METER_EXPLORER],
])('should redirect the old views route %s to %s', (from, to) => {
renderPrivateRoute({ initialRoute: from });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/logs/saved-views',
);
});
it('should redirect /traces-save-views to /traces/saved-views', () => {
renderPrivateRoute({ initialRoute: '/traces-save-views' });
expect(screen.getByTestId('location-display')).toHaveTextContent(
'/traces/saved-views',
);
expect(screen.getByTestId('location-display').textContent).toBe(to);
});
it('should redirect /settings/access-tokens to /settings/service-accounts', () => {
@@ -1635,11 +1632,6 @@ describe('PrivateRoute', () => {
['LIST_LICENSES', ROUTES.LIST_LICENSES, USER_ROLES.EDITOR as ROLES],
['ONBOARDING', ROUTES.ONBOARDING, USER_ROLES.VIEWER as ROLES],
['APPLICATION', ROUTES.APPLICATION, USER_ROLES.ANONYMOUS as ROLES],
[
'METRICS_EXPLORER_VIEWS',
ROUTES.METRICS_EXPLORER_VIEWS,
USER_ROLES.ANONYMOUS as ROLES,
],
];
it.each(authzRouteRolePairs)(

View File

@@ -513,8 +513,12 @@ export const LIST_LICENSES: AppRoutes = {
export const oldNewRoutesMapping: Record<string, string> = {
'/pipelines': '/logs/pipelines',
'/logs-explorer': '/logs/logs-explorer',
'/logs-save-views': '/logs/saved-views',
'/traces-save-views': '/traces/saved-views',
'/logs-save-views': '/logs/logs-explorer',
'/traces-save-views': '/traces-explorer',
'/logs/saved-views': '/logs/logs-explorer',
'/traces/saved-views': '/traces-explorer',
'/metrics-explorer/views': '/metrics-explorer/explorer',
'/meter/explorer/views': '/meter/explorer',
'/settings/access-tokens': '/settings/service-accounts',
'/settings/api-keys': '/settings/service-accounts',
'/messaging-queues': '/messaging-queues/overview',

View File

@@ -14,6 +14,7 @@ import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useHandleExplorerTabChangeHooks from 'hooks/useHandleExplorerTabChange';
import * as appContextHooks from 'providers/App/App';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
@@ -147,7 +148,9 @@ function renderExplorer(): void {
<MemoryRouter>
<Provider store={store}>
<ErrorModalProvider>
<Explorer />
<PreferenceContextProvider>
<Explorer />
</PreferenceContextProvider>
</ErrorModalProvider>
</Provider>
</MemoryRouter>

View File

@@ -1,11 +1,3 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
return true;
}

View File

@@ -1,47 +0,0 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { countControl } from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
logsSavedViewsResponse,
SAVED_VIEW_MAX,
SAVED_VIEWS_PAGE_SIZE,
savedViewWriteResponse,
} from './__story_mockdata__/savedViews';
const LIST = 'Views · list';
export const savedViewsMocks = defineStoryMocks({
controls: {
views: countControl('Saved views', {
group: LIST,
description: `The views the org saved for logs. Past ${SAVED_VIEWS_PAGE_SIZE} the table paginates.`,
value: 4,
max: SAVED_VIEW_MAX,
}),
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/explorer/views',
response.json(() => logsSavedViewsResponse(values.views)),
),
rest.put(
'http://localhost/api/v1/explorer/views/:viewId',
response.json(savedViewWriteResponse),
),
rest.delete(
'http://localhost/api/v1/explorer/views/:viewId',
response.json(savedViewWriteResponse),
),
],
config: () => ({ route: ROUTES.LOGS_SAVE_VIEWS }),
});

View File

@@ -1,88 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { savedViewsMocks } from './SavedViews.stories.mocks';
import LogsModulePage from '../../LogsModulePage';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const pageStory = storyMocks(savedViewsMocks, { layout: 'app' });
/**
* Saved views on the logs explorer, and the actions the legacy editor role gates.
*
* Route: `/logs/saved-views`.
*/
const meta = {
title: 'Pages/Logs/Saved Views',
tags: ['role-gated', 'play'],
component: LogsModulePage,
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<SavedViewsArgs>;
export default meta;
type Story = StoryObj<SavedViewsArgs>;
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** Row actions are per-view icons, so a story that opens one picks the first. */
const openFirstAction = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const [action] = await within(canvasElement).findAllByTestId(
testId,
undefined,
untilLoaded,
);
await userEvent.click(action);
};
/**
* The views tab: every view the org saved for logs, who saved it and when, with
* the colour the view picker shows it under.
*/
export const Default: Story = {};
/** A workspace where nobody has saved a view yet. */
export const NoViews: Story = {
args: { views: 0 },
};
/** Enough views to paginate, which is the only place the second page shows up. */
export const Paginated: Story = {
args: { views: 8 },
};
/** Renaming a view: its label and the colour the picker shows it under. */
export const EditView: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstAction(canvasElement, 'edit-view');
await screen.findByText('Edit view details', undefined, untilLoaded);
},
};
/** The confirmation a view has to pass before it is deleted. */
export const DeleteView: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openFirstAction(canvasElement, 'delete-view');
await screen.findByText(
/are you sure you want to delete/i,
undefined,
untilLoaded,
);
},
};
/** A viewer: the views are listed, none of them editable. */
export const ViewerAccess: Story = {
args: { access: 'viewer' },
};

View File

@@ -64,6 +64,7 @@ import {
metricTemporalityOf,
metricTypeOf,
metricsSavedViewsResponse,
metricsSavedViewsV2Response,
metricsStatsResponse,
metricsTreemapResponse,
REDUCTION_RULE_MAX,
@@ -517,6 +518,11 @@ export const metricsMocks = defineStoryMocks({
response.json(() => metricsSavedViewsResponse(values.savedViews)),
),
rest.get(
'http://localhost/api/v2/saved_views',
response.json(() => metricsSavedViewsV2Response(values.savedViews)),
),
rest.post(
'http://localhost/api/v5/query_range',
response.json(async (req) => {

View File

@@ -16,6 +16,7 @@ import type {
InspectMetrics200,
ListMetricReductionRules200,
ListMetrics200,
ListSavedViews200,
MetricreductionruletypesGettableReductionRuleDTO,
MetricsexplorertypesStatDTO,
MetricsexplorertypesTreemapEntryDTO,
@@ -24,7 +25,12 @@ import {
MetricreductionruletypesMatchTypeDTO,
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregationDTOSignal as MetricsSignal,
Querybuildertypesv5QueryEnvelopeBuilderDTOType,
Querybuildertypesv5RequestTypeDTO,
SavedviewtypesPanelTypeDTO,
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { explorerView } from 'mocks-server/__mockdata__/explorer_views';
import type { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
@@ -785,6 +791,33 @@ export const metricsSavedViewsResponse = (count: number): AllViewsProps => ({
})),
});
export const metricsSavedViewsV2Response = (
count: number,
): ListSavedViews200 => ({
status: 'success',
data: VIEW_NAMES.slice(0, count).map((displayName, index) => ({
id: `storybook-metrics-view-${index + 1}`,
name: `storybook-metrics-view-${index + 1}`,
source: SavedviewtypesSourceDTO.metrics,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdAt: '2026-08-20T09:00:00Z',
createdBy: 'ada@signoz.io',
updatedAt: '2026-08-20T09:00:00Z',
updatedBy: 'ada@signoz.io',
spec: {
displayName,
panelType: SavedviewtypesPanelTypeDTO.graph,
requestType: Querybuildertypesv5RequestTypeDTO.time_series,
queries: [
{
type: Querybuildertypesv5QueryEnvelopeBuilderDTOType.builder_query,
spec: { name: 'A', signal: MetricsSignal.metrics },
},
],
},
})),
});
const FIELD_KEYS = [
'service.name',
'deployment.environment',

View File

@@ -10,7 +10,6 @@ import {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
cleanup,
fireEvent,
render,
@@ -316,99 +315,17 @@ describe('TracesExplorer -', () => {
});
});
it('test for explorer options', async () => {
const { getByText, getByTestId } = renderWithTracesExplorerRouter(
<TracesExplorer />,
['/traces-explorer/?panelType=list&selectedExplorerView=list'],
);
// assert explorer options - action btns
[
'Save this view',
'Create an Alert',
'Add to Dashboard',
'Select a view',
].forEach((val) => expect(getByText(val)).toBeInTheDocument());
const hideExplorerOption = getByTestId('hide-toolbar');
expect(hideExplorerOption).toBeInTheDocument();
fireEvent.click(hideExplorerOption);
// explorer options should hide and show btn should be present
await expect(
screen.findByTestId('show-explorer-option'),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('hide-toolbar')).toBeNull();
// show explorer options
const showExplorerOption = screen.getByTestId('show-explorer-option');
expect(showExplorerOption).toBeInTheDocument();
fireEvent.click(showExplorerOption);
// explorer options should show and hide btn should be present
await expect(
screen.findByTestId('hide-toolbar'),
).resolves.toBeInTheDocument();
});
it('select a view options - assert and save this view', async () => {
const { container } = renderWithTracesExplorerRouter(<TracesExplorer />, [
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const viewSearchInput = await waitFor(() => {
const el = container.querySelector(
'.view-options .ant-select-selection-search-input',
) as HTMLElement;
expect(el).toBeInTheDocument();
return el;
});
fireEvent.mouseDown(viewSearchInput);
// The saved-views request has to land and re-render the dropdown before the
// option exists; findByRole's 1s default is not enough on a loaded runner.
await expect(
screen.findByRole('option', { name: 'R-test panel' }, { timeout: 5000 }),
).resolves.toBeInTheDocument();
// save this view
fireEvent.click(await screen.findByText('Save this view'));
const saveViewModalInput = await screen.findByPlaceholderText(
'e.g. External http method view',
);
expect(saveViewModalInput).toBeInTheDocument();
const saveViewModal = await waitFor(() => {
const el = document.querySelector('.ant-modal-content') as HTMLElement;
expect(el).toBeInTheDocument();
return el;
});
await act(async () =>
fireEvent.change(saveViewModalInput, { target: { value: 'test view' } }),
);
expect(saveViewModalInput).toHaveValue('test view');
await act(async () => {
fireEvent.click(within(saveViewModal).getByTestId('save-view-btn'));
});
await waitFor(() => {
expect(successNotification).toHaveBeenCalledWith({
message: 'View Saved Successfully',
});
});
}, 15000);
it('create a dashboard btn assert', async () => {
renderWithTracesExplorerRouter(<TracesExplorer />, [
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const createDashboardBtn = await screen.findByText('Add to Dashboard');
expect(createDashboardBtn).toBeInTheDocument();
const createDashboardBtn = await screen.findByTestId(
'explorer-add-to-dashboard',
);
await waitFor(() =>
expect(createDashboardBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
fireEvent.click(createDashboardBtn);
const createDashboardModal = (await screen.findByRole(
@@ -431,8 +348,10 @@ describe('TracesExplorer -', () => {
'/traces-explorer/?panelType=list&selectedExplorerView=list',
]);
const createAlertBtn = await screen.findByText('Create an Alert');
expect(createAlertBtn).toBeInTheDocument();
const createAlertBtn = await screen.findByTestId('explorer-create-alert');
await waitFor(() =>
expect(createAlertBtn).not.toHaveAttribute('aria-disabled', 'true'),
);
fireEvent.click(createAlertBtn);
expect(historyPush).toHaveBeenCalledWith(

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { toast } from '@signozhq/ui/sonner';
import { screen, userEvent, waitFor } from 'storybook/test';
import { screen, userEvent } from 'storybook/test';
import type { GlobalMockArgs } from '../globals';
@@ -14,10 +14,10 @@ function EmptyPage(): JSX.Element {
const untilLoaded = { timeout: 10_000 };
/**
* The strip along the foot of the shell, behind the `SAVED_VIEW_ENABLED` flag:
* what the page puts on the left, the build version when it puts nothing, and
* the shell's own Ask Noz and Support on the right. Support follows the chat
* support the workspace has, the way the floating bubble it replaces did.
* The strip along the foot of the shell: what the page puts on the left, the
* build version when it puts nothing, and the shell's own Ask Noz and Support
* on the right. Support follows the chat support the workspace has, the way
* the floating bubble it replaces did.
*
* Route: every page inside the app shell; these stories use `/home`.
*/
@@ -78,38 +78,6 @@ export const Tooltips: Story = {
args: { noz: true, support: 'pylon', tooltipsOpen: true },
};
/**
* The flag off, on a cloud trial without a card: no strip, and chat support is
* the floating bubble in the corner.
*/
export const WithoutStrip: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
};
/** The Add Credit Card modal, opened from the floating bubble with the flag off. */
export const WithoutStripAddCardModal: Story = {
args: { bottomStrip: false, noz: true, support: 'add-card' },
play: async (): Promise<void> => {
// The bubble is an icon with no label or test id of its own.
const bubble = await waitFor(() => {
const found = document.querySelector<HTMLElement>(
'.chat-support-gateway-btn',
);
if (!found) {
throw new Error('the floating support bubble never rendered');
}
return found;
}, untilLoaded);
await userEvent.click(bubble);
await screen.findByRole('dialog', {
name: /Add Credit Card for Chat Support/i,
});
},
};
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
const raiseToast: NonNullable<Story['play']> = async () => {
toast.success('Service account deleted');
@@ -117,16 +85,10 @@ const raiseToast: NonNullable<Story['play']> = async () => {
};
/**
* A toast with the strip on. Sonner pins itself to the foot of the viewport, so
* A toast above the strip. Sonner pins itself to the foot of the viewport, so
* without an offset it lands on top of Ask Noz and Support.
*/
export const WithToast: Story = {
args: { noz: true, support: 'pylon' },
play: raiseToast,
};
/** The same toast with the flag off, back at sonner's own inset from the viewport. */
export const WithoutStripToast: Story = {
args: { bottomStrip: false, noz: true, support: 'pylon' },
play: raiseToast,
};

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -7,6 +8,10 @@ import { DataSource } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { sanitizeOrderByForExplorer } from 'utils/sanitizeOrderBy';
jest.mock('@sentry/react', () => ({
captureEvent: jest.fn(),
}));
jest.mock('utils/aggregationConverter', () => ({
getParsedAggregationOptionsForOrderBy: jest.fn(),
}));
@@ -117,6 +122,7 @@ describe('sanitizeOrderByForExplorer', () => {
const result = sanitizeOrderByForExplorer(query);
expect(result).toStrictEqual([]);
expect(Sentry.captureEvent).not.toHaveBeenCalled();
});
it('handles missing orderBy by returning an empty array', () => {

View File

@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/react';
import {
IBuilderQuery,
OrderByPayload,
@@ -20,15 +19,5 @@ export function sanitizeOrderByForExplorer(
const current = query.orderBy || [];
const hasInvalidOrderBy = current.some((o) => !allowed.has(o.columnName));
if (hasInvalidOrderBy) {
Sentry.captureEvent({
message: `Invalid orderBy: current: ${JSON.stringify(
current,
)} - allowed: ${JSON.stringify(Array.from(allowed))}`,
level: 'warning',
});
}
return current.filter((o) => allowed.has(o.columnName));
}

View File

@@ -143,6 +143,15 @@ func (q *builderQuery[T]) Fingerprint() string {
parts = append(parts, fmt.Sprintf("aggs=[%s]", strings.Join(aggParts, ",")))
}
// A metrics heatmap carries its axis on the aggregation, which the loop above
// already fingerprinted. Logs and traces carry it on the spec, and the
// aggregation is the same count() whichever field is bucketed.
if q.kind == qbtypes.RequestTypeHeatmap && q.spec.Signal != telemetrytypes.SignalMetrics && q.spec.BucketOptions != nil && q.spec.BucketOptions.Field != nil {
parts = append(parts, fmt.Sprintf("buckets=%s:%s",
fingerprintFieldKey(*q.spec.BucketOptions.Field),
fingerprintHeatmapBucketing(q.spec.BucketOptions.ToHeatmapBucketing())))
}
// Add filter if present
if q.spec.Filter != nil && q.spec.Filter.Expression != "" {
parts = append(parts, fmt.Sprintf("filter=%s", q.spec.Filter.Expression))

View File

@@ -183,6 +183,14 @@ func postProcessBuilderQuery[T any](
req *qbtypes.QueryRangeRequest,
) *qbtypes.Result {
// A disabled query feeds a formula as a time series, so only an enabled one
// came back bucketed.
if req.RequestType == qbtypes.RequestTypeHeatmap && !query.Disabled && query.BucketOptions != nil {
if tsData, ok := result.Value.(*qbtypes.TimeSeriesData); ok {
resolveHeatmapBucketAxis(tsData, query.BucketOptions.ToHeatmapBucketing())
}
}
result = q.applySeriesLimit(result, query.Limit, query.Order)
// Apply functions

View File

@@ -250,27 +250,30 @@ func (q *querier) buildQueries(
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid AI builder query spec %T", query.Spec)
}
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryTypeBuilder:
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
stmtBuilder := q.logStmtBuilder
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, requestType, tmplVars, q.builderConfig)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -281,13 +284,7 @@ func (q *querier) buildQueries(
if missingMetricQuerySet[spec.Name] {
continue
}
requestType := req.RequestType
if requestType == qbtypes.RequestTypeHeatmap && spec.Disabled {
// A disabled query in a heatmap request feeds a formula, and the
// formula converts time series into heatmap data, so its inputs
// run as time series queries.
requestType = qbtypes.RequestTypeTimeSeries
}
requestType := requestTypeForQuery(req.RequestType, spec.Disabled)
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, requestType)
var bq *builderQuery[qbtypes.MetricAggregation]
@@ -1201,6 +1198,17 @@ func clampStep(qe *qbtypes.QueryEnvelope, recommended, min uint64, warnings *[]s
}
// extractShiftFromBuilderQuery extracts the shift value from timeShift function if present.
// requestTypeForQuery downgrades a disabled query in a heatmap request to a
// time series one. A disabled query feeds a formula rather than the chart, and
// the formula is what turns time series into cells, so its inputs state no
// bucket axis of their own.
func requestTypeForQuery(requestType qbtypes.RequestType, disabled bool) qbtypes.RequestType {
if requestType == qbtypes.RequestTypeHeatmap && disabled {
return qbtypes.RequestTypeTimeSeries
}
return requestType
}
func extractShiftFromBuilderQuery[T any](spec qbtypes.QueryBuilderQuery[T]) int64 {
for _, fn := range spec.Functions {
if fn.Name == qbtypes.FunctionNameTimeShift && len(fn.Args) > 0 {

126
pkg/querybuilder/heatmap.go Normal file
View File

@@ -0,0 +1,126 @@
package querybuilder
import (
"context"
"fmt"
"math"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
// heatmapBucketValueAlias names the value a heatmap's bounds are cut from. The
// bounds read it several times, so it is declared once as a WITH expression
// alias rather than repeated.
const heatmapBucketValueAlias = "__bucket_value"
// RenderHeatmapBucketExprs renders the bucket (min, max] that valueExpr falls
// in. Only the bucket under everything the axis covers is open below, and only
// the one over it is open above.
//
// valueExpr is repeated several times in each expression, so it has to be free
// of placeholders — HeatmapBucketColumns passes an alias for that reason.
func RenderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (minExpr, maxExpr string, err error) {
switch bucketing.Kind {
case qbtypes.BucketsKindLinear:
return renderLinearBucketExprs(bucketing, valueExpr)
case qbtypes.BucketsKindLog:
return renderLogBucketExprs(valueExpr)
default:
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"invalid bucketOptions kind: %s", bucketing.Kind.StringValue())
}
}
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing, valueExpr string) (string, string, error) {
maxValue := FormatHeatmapFloat(bucketing.MaxValue)
numBuckets := strconv.Itoa(bucketing.NumBuckets)
index := fmt.Sprintf("least(greatest(ceil(%s * %s / %s), 1), %s)", valueExpr, numBuckets, maxValue, numBuckets)
minExpr := fmt.Sprintf(
"multiIf(%s, NULL, %s <= 0, toFloat64('-Inf'), %s > %s, toFloat64(%s), (%s - 1) * %s / %s)",
absentValue(valueExpr), valueExpr, valueExpr, maxValue, maxValue, index, maxValue, numBuckets,
)
maxExpr := fmt.Sprintf(
"multiIf(%s, NULL, %s <= 0, toFloat64(0), %s > %s, toFloat64('+Inf'), %s * %s / %s)",
absentValue(valueExpr), valueExpr, valueExpr, maxValue, index, maxValue, numBuckets,
)
return minExpr, maxExpr, nil
}
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
// postprocessing folds the axis down afterwards.
func renderLogBucketExprs(valueExpr string) (string, string, error) {
bucketsPerDoubling := FormatHeatmapFloat(math.Exp2(qbtypes.MaxLogScale))
lowest := FormatHeatmapFloat(qbtypes.MinLogUpperBound)
highest := FormatHeatmapFloat(qbtypes.MaxLogUpperBound)
minExpr := fmt.Sprintf(
"multiIf(%s, NULL, %s <= 0, toFloat64('-Inf'), %s <= %s, toFloat64(0), %s > %s, toFloat64(%s), pow(2, (ceil(log2(%s) * %s) - 1) / %s))",
absentValue(valueExpr), valueExpr, valueExpr, lowest, valueExpr, highest, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
)
maxExpr := fmt.Sprintf(
"multiIf(%s, NULL, %s <= 0, toFloat64(0), %s <= %s, %s, %s > %s, toFloat64('+Inf'), pow(2, ceil(log2(%s) * %s) / %s))",
absentValue(valueExpr), valueExpr, valueExpr, lowest, lowest, valueExpr, highest, valueExpr, bucketsPerDoubling, bucketsPerDoubling,
)
return minExpr, maxExpr, nil
}
// absentValue tests for a row the axis has no place for. A log or a span need
// not carry the bucketed field at all, and without this the comparisons below
// read a missing value as the smallest one: greatest(NULL, 1) is 1 in
// ClickHouse, so an absent value would be counted in the lowest bucket. A NULL
// bound instead leaves the row with no bucket, and the reader drops it.
func absentValue(valueExpr string) string {
return fmt.Sprintf("isNull(%s)", valueExpr)
}
// FormatHeatmapFloat renders a float64 as the shortest literal that reads back
// as the same value, so an upper bound computed from it is identical on every
// row.
func FormatHeatmapFloat(v float64) string {
return strconv.FormatFloat(v, 'g', -1, 64)
}
// HeatmapBucketColumns adds the bucket a row's value falls in to sb's select
// list, as the (min, max] pair a heatmap statement reports. key is the field
// the axis is cut from, already adjusted for context and data type.
//
// The bounds read the value through an expression alias rather than repeating
// it, so the returned fragment has to be declared alongside the caller's CTEs.
// It carries no arguments of its own.
func HeatmapBucketColumns(
ctx context.Context,
info qbtypes.QueryInfo,
storage qbtypes.Storage,
sb *sqlbuilder.SelectBuilder,
key *telemetrytypes.TelemetryFieldKey,
bucketOptions *qbtypes.BucketOptions,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
) (valueFragment string, groupByAliases []string, warnings []string, err error) {
// Resolved directly rather than through ResolveColumn: a row whose value is
// absent has no bucket and drops out, so a key nothing carries draws an empty
// heatmap, and the warning is the only thing that says why.
resolved, err := Resolve(ctx, info, storage, key, qbtypes.FilterOperatorUnknown, nil, keys)
if err != nil {
return "", nil, nil, err
}
valueExpr, err := Column(ctx, info, storage, resolved, telemetrytypes.FieldDataTypeFloat64)
if err != nil {
return "", nil, nil, err
}
bucketMin, bucketMax, err := RenderHeatmapBucketExprs(bucketOptions.ToHeatmapBucketing(), heatmapBucketValueAlias)
if err != nil {
return "", nil, nil, err
}
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMin, qbtypes.HeatmapBucketMinColumn)))
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", bucketMax, qbtypes.HeatmapBucketMaxColumn)))
valueFragment = sqlbuilder.Escape(fmt.Sprintf("%s AS %s", valueExpr, heatmapBucketValueAlias))
return valueFragment, []string{qbtypes.HeatmapBucketMinColumn, qbtypes.HeatmapBucketMaxColumn}, resolved.Warnings, nil
}

View File

@@ -141,7 +141,9 @@ func (b *logQueryStatementBuilder) Build(
case qbtypes.RequestTypeRaw, qbtypes.RequestTypeRawStream:
stmt, err = b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables)
case qbtypes.RequestTypeTimeSeries:
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables, nil)
case qbtypes.RequestTypeHeatmap:
stmt, err = b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables, query.BucketOptions)
case qbtypes.RequestTypeScalar:
stmt, err = b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, false, variables)
default:
@@ -173,6 +175,15 @@ func (b *logQueryStatementBuilder) costGuardFor(ctx context.Context, orgID value
return &qbtypes.CostGuard{Warning: querybuilder.SearchWarning, MaxScanRows: maxScanRows}
}
// heatmapBucketField is the field a heatmap cuts its axis from, nil for every
// other request type. Validation has already refused a heatmap without one.
func heatmapBucketField(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]) *telemetrytypes.TelemetryFieldKey {
if query.BucketOptions == nil {
return nil
}
return query.BucketOptions.Field
}
func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], bodyJSONEnabled bool) ([]*telemetrytypes.FieldKeySelector, []string) {
var keySelectors []*telemetrytypes.FieldKeySelector
var warnings []string
@@ -217,6 +228,15 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation], bo
})
}
if field := heatmapBucketField(query); field != nil {
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalLogs,
FieldContext: field.FieldContext,
FieldDataType: field.FieldDataType,
})
}
for idx := range keySelectors {
keySelectors[idx].Signal = telemetrytypes.SignalLogs
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
@@ -291,6 +311,9 @@ func (b *logQueryStatementBuilder) adjustKeys(ctx context.Context, keys map[stri
for idx := range query.Order {
actions = append(actions, b.adjustKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
}
if field := heatmapBucketField(query); field != nil {
actions = append(actions, b.adjustKey(field, keys)...)
}
for _, action := range actions {
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
@@ -430,6 +453,9 @@ func (b *logQueryStatementBuilder) buildListQuery(
return stmt, nil
}
// buildTimeSeriesQuery builds one row per (time bucket, group). A non-nil
// bucketOptions makes it a heatmap instead: the bucket a row's value falls in
// joins the group keys, so a row is one cell.
func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
ctx context.Context,
orgID valuer.UUID,
@@ -438,6 +464,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
bucketOptions *qbtypes.BucketOptions,
) (*qbtypes.Statement, error) {
var (
@@ -476,6 +503,18 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
}
var bucketAliases, bucketWarnings []string
if bucketOptions != nil {
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
ctx, info, b.storage, sb, columnKey(bucketOptions.Field), bucketOptions, keys,
)
if err != nil {
return nil, err
}
cteFragments = append(cteFragments, valueFragment)
bucketAliases, bucketWarnings = aliases, warnings
}
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
@@ -521,6 +560,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
// Group by all dimensions
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
// Rewrite having expression to use SQL column names
rewriter := querybuilder.NewHavingExpressionRewriter()
@@ -555,6 +595,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
} else {
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForLogs(query.Having.Expression, query.Aggregations)
@@ -589,7 +630,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, bucketWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
CostGuard: b.costGuardFor(ctx, orgID, preparedWhereClause.RequiresCostGuard),
}

View File

@@ -4,14 +4,11 @@ import (
"context"
"fmt"
"log/slog"
"math"
"slices"
"strconv"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -911,6 +908,10 @@ const (
heatmapValueAlias = "__result_0"
heatmapWindow = "__heatmap_window"
// The column __spatial_aggregation_cte carries the sample in, which is what
// the bucket axis is cut from.
heatmapValueColumn = "value"
)
func isHistogramBucket(k qbtypes.GroupByKey) bool { return k.Name == histogramBucketKey }
@@ -976,7 +977,7 @@ func buildValueHeatmapFinalSelect(
args []any,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
) (*qbtypes.Statement, error) {
bucketMin, bucketMax, err := renderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing)
bucketMin, bucketMax, err := querybuilder.RenderHeatmapBucketExprs(*query.Aggregations[0].HeatmapBucketing, heatmapValueColumn)
if err != nil {
return nil, err
}
@@ -997,61 +998,6 @@ func buildValueHeatmapFinalSelect(
return &qbtypes.Statement{Query: combined + q, Args: append(args, a...)}, nil
}
// renderHeatmapBucketExprs renders the bucket (min, max] that `value` falls in.
// Only the bucket under everything the axis covers is open below, and only the
// one over it is open above.
func renderHeatmapBucketExprs(bucketing qbtypes.HeatmapBucketing) (minExpr, maxExpr string, err error) {
switch bucketing.Kind {
case qbtypes.BucketsKindLinear:
return renderLinearBucketExprs(bucketing)
case qbtypes.BucketsKindLog:
return renderLogBucketExprs()
default:
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput,
"unsupported bucketsScaling %q for heatmap requests", bucketing.Kind.StringValue())
}
}
func renderLinearBucketExprs(bucketing qbtypes.HeatmapBucketing) (string, string, error) {
maxValue := formatFloat(bucketing.MaxValue)
numBuckets := strconv.Itoa(bucketing.NumBuckets)
index := fmt.Sprintf("least(greatest(ceil(value * %s / %s), 1), %s)", numBuckets, maxValue, numBuckets)
minExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64('-Inf'), value > %s, toFloat64(%s), (%s - 1) * %s / %s)",
maxValue, maxValue, index, maxValue, numBuckets,
)
maxExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64(0), value > %s, toFloat64('+Inf'), %s * %s / %s)",
maxValue, index, maxValue, numBuckets,
)
return minExpr, maxExpr, nil
}
// ClickHouse buckets at MaxLogScale whatever HeatmapBucketing.LogScale asks for;
// postprocessing folds the axis down afterwards.
func renderLogBucketExprs() (string, string, error) {
bucketsPerDoubling := formatFloat(math.Exp2(qbtypes.MaxLogScale))
lowest := formatFloat(qbtypes.MinLogUpperBound)
highest := formatFloat(qbtypes.MaxLogUpperBound)
minExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64('-Inf'), value <= %s, toFloat64(0), value > %s, toFloat64(%s), pow(2, (ceil(log2(value) * %s) - 1) / %s))",
lowest, highest, highest, bucketsPerDoubling, bucketsPerDoubling,
)
maxExpr := fmt.Sprintf(
"multiIf(value <= 0, toFloat64(0), value <= %s, %s, value > %s, toFloat64('+Inf'), pow(2, ceil(log2(value) * %s) / %s))",
lowest, lowest, highest, bucketsPerDoubling, bucketsPerDoubling,
)
return minExpr, maxExpr, nil
}
// formatFloat renders a float64 as the shortest literal that reads back as the
// same value, so an upper bound computed from it is identical on every row.
func formatFloat(v float64) string {
return strconv.FormatFloat(v, 'g', -1, 64)
}
func GroupByColumnAlias(i int, name string) string {
if name == histogramBucketKey {
return clickhousesql.Identifier(histogramBucketKey)

View File

@@ -374,7 +374,7 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket_max",
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(isNull(value), NULL, value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(isNull(value), NULL, value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket_max",
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
},
expectedErr: nil,
@@ -408,7 +408,7 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(value <= 0, toFloat64('-Inf'), value > 500, toFloat64(500), (least(greatest(ceil(value * 25 / 500), 1), 25) - 1) * 500 / 25) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value > 500, toFloat64('+Inf'), least(greatest(ceil(value * 25 / 500), 1), 25) * 500 / 25) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket_max",
Query: "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_host.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'host.name') AS `__GROUP_BY_KEY_0_host.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_host.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_host.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_host.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_host.name`) SELECT ts, `__GROUP_BY_KEY_0_host.name`, multiIf(isNull(value), NULL, value <= 0, toFloat64('-Inf'), value > 500, toFloat64(500), (least(greatest(ceil(value * 25 / 500), 1), 25) - 1) * 500 / 25) AS __bucket_min, multiIf(isNull(value), NULL, value <= 0, toFloat64(0), value > 500, toFloat64('+Inf'), least(greatest(ceil(value * 25 / 500), 1), 25) * 500 / 25) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_host.name`, ts, __bucket_max",
Args: []any{"system.memory.usage", uint64(1747936800000), uint64(1747983420000), "unspecified", "system.memory.usage", uint64(1747947360000), uint64(1747983420000), 0},
},
expectedErr: nil,
@@ -474,7 +474,7 @@ func TestStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, __bucket_max",
Query: "WITH __temporal_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(row_number() OVER rate_window = 1, nan, (per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) < 0, per_series_value, per_series_value - lagInFrame(per_series_value, 1) OVER rate_window) AS per_series_value FROM (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, `__GROUP_BY_KEY_0_service.name`, max(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_service.name` ORDER BY fingerprint, ts) WINDOW rate_window AS (PARTITION BY fingerprint ORDER BY fingerprint, ts)), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_service.name`, sum(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT ts, `__GROUP_BY_KEY_0_service.name`, multiIf(isNull(value), NULL, value <= 0, toFloat64('-Inf'), value <= 2.3283064365386963e-10, toFloat64(0), value > 1.8446744073709552e+19, toFloat64(1.8446744073709552e+19), pow(2, (ceil(log2(value) * 16) - 1) / 16)) AS __bucket_min, multiIf(isNull(value), NULL, value <= 0, toFloat64(0), value <= 2.3283064365386963e-10, 2.3283064365386963e-10, value > 1.8446744073709552e+19, toFloat64('+Inf'), pow(2, ceil(log2(value) * 16) / 16)) AS __bucket_max, toFloat64(1) AS __result_0 FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts, __bucket_max",
Args: []any{"signoz_calls_total", uint64(1747936800000), uint64(1747983420000), "cumulative", "signoz_calls_total", uint64(1747947300000), uint64(1747983420000), 0},
},
expectedErr: nil,

View File

@@ -176,7 +176,9 @@ func (b *traceQueryStatementBuilder) Build(
case qbtypes.RequestTypeRaw:
return b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables, isSelectFieldsEmpty)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables, nil)
case qbtypes.RequestTypeHeatmap:
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables, query.BucketOptions)
case qbtypes.RequestTypeScalar:
return b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, variables, false, false)
case qbtypes.RequestTypeTrace:
@@ -212,6 +214,10 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
keySelectors = append(keySelectors, keySelectorsForField(query.Order[idx].Key.TelemetryFieldKey)...)
}
if field := heatmapBucketField(query); field != nil {
keySelectors = append(keySelectors, keySelectorsForField(*field)...)
}
for idx := range keySelectors {
keySelectors[idx].Signal = telemetrytypes.SignalTraces
keySelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
@@ -297,10 +303,22 @@ func adjustTraceKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, query
for idx := range query.Order {
actions = append(actions, adjustTraceKey(&query.Order[idx].Key.TelemetryFieldKey, keys)...)
}
if field := heatmapBucketField(*query); field != nil {
actions = append(actions, adjustTraceKey(field, keys)...)
}
return actions
}
// heatmapBucketField is the field a heatmap cuts its axis from, nil for every
// other request type. Validation has already refused a heatmap without one.
func heatmapBucketField(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) *telemetrytypes.TelemetryFieldKey {
if query.BucketOptions == nil {
return nil
}
return query.BucketOptions.Field
}
// adjustTraceKey resolves a single TelemetryFieldKey against the keys map.
func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) []string {
@@ -557,6 +575,9 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
return stmt, nil
}
// buildTimeSeriesQuery builds one row per (time bucket, group). A non-nil
// bucketOptions makes it a heatmap instead: the bucket a row's value falls in
// joins the group keys, so a row is one cell.
func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
ctx context.Context,
orgID valuer.UUID,
@@ -565,6 +586,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
bucketOptions *qbtypes.BucketOptions,
) (*qbtypes.Statement, error) {
var (
@@ -604,6 +626,18 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
fieldNames = append(fieldNames, sqlbuilder.Escape(fieldAlias))
}
var bucketAliases, bucketWarnings []string
if bucketOptions != nil {
valueFragment, aliases, warnings, err := querybuilder.HeatmapBucketColumns(
ctx, info, b.storage, sb, bucketOptions.Field, bucketOptions, keys,
)
if err != nil {
return nil, err
}
cteFragments = append(cteFragments, valueFragment)
bucketAliases, bucketWarnings = aliases, warnings
}
// Aggregations
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
@@ -646,6 +680,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
// Group by all dimensions
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
@@ -679,6 +714,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
} else {
sb.GroupBy("ts")
sb.GroupBy(fieldNames...)
sb.GroupBy(bucketAliases...)
if query.Having != nil && query.Having.Expression != "" {
rewriter := querybuilder.NewHavingExpressionRewriter()
rewrittenExpr, err := rewriter.RewriteForTraces(query.Having.Expression, query.Aggregations)
@@ -713,7 +749,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
stmt := &qbtypes.Statement{
Query: finalSQL,
Args: finalArgs,
Warnings: preparedWhereClause.Warnings,
Warnings: append(preparedWhereClause.Warnings, bucketWarnings...),
WarningsDocURL: preparedWhereClause.WarningsDocURL,
}

View File

@@ -729,7 +729,11 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
type BucketOptions struct {
Kind BucketsKind `json:"kind"`
Spec any `json:"spec"`
// Field names the numeric field whose values the axis is cut from. A log or
// a span carries many, so one has to be named; a metrics query and a formula
// each carry exactly one value and name none.
Field *telemetrytypes.TelemetryFieldKey `json:"field,omitempty"`
Spec any `json:"spec"`
}
type BucketsKind struct {
@@ -768,14 +772,16 @@ type LogBucketsSpec struct {
func (b *BucketOptions) UnmarshalJSON(data []byte) error {
var shadow struct {
Kind BucketsKind `json:"kind"`
Spec json.RawMessage `json:"spec"`
Kind BucketsKind `json:"kind"`
Field *telemetrytypes.TelemetryFieldKey `json:"field"`
Spec json.RawMessage `json:"spec"`
}
if err := binding.JSON.BindBody(bytes.NewReader(data), &shadow, binding.WithDisallowUnknownFields(true)); err != nil {
return err
}
b.Kind = shadow.Kind
b.Field = shadow.Field
// An absent spec is a malformed pair rather than a request for defaults;
// `"spec": {}` asks for those.
@@ -818,13 +824,15 @@ func (b *BucketOptions) UnmarshalJSON(data []byte) error {
// PrepareJSONSchema pointing at components that were never emitted. `kind` is
// required:"true" on both so oapi-codegen renders the discriminator non-pointer.
type bucketOptionsLinear struct {
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
Spec LinearBucketsSpec `json:"spec" required:"true" description:"The evenly spaced bucket specification."`
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
Field *telemetrytypes.TelemetryFieldKey `json:"field,omitempty" description:"The numeric field the axis is cut from. Required for logs and traces, rejected for metrics and formulas."`
Spec LinearBucketsSpec `json:"spec" required:"true" description:"The evenly spaced bucket specification."`
}
type bucketOptionsLog struct {
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
Spec LogBucketsSpec `json:"spec" required:"true" description:"The logarithmic bucket specification."`
Kind BucketsKind `json:"kind" required:"true" description:"How the upper bounds are spaced."`
Field *telemetrytypes.TelemetryFieldKey `json:"field,omitempty" description:"The numeric field the axis is cut from. Required for logs and traces, rejected for metrics and formulas."`
Spec LogBucketsSpec `json:"spec" required:"true" description:"The logarithmic bucket specification."`
}
var _ jsonschema.OneOfExposer = BucketOptions{}

View File

@@ -879,6 +879,9 @@ func (r *QueryRangeRequest) validateHeatmap() error {
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
return err
}
if err := refuseBucketField(spec.BucketOptions, "a metrics query"); err != nil {
return err
}
if spec.Disabled {
continue
}
@@ -887,6 +890,9 @@ func (r *QueryRangeRequest) validateHeatmap() error {
if err := validateHeatmapQuery(spec.Functions, spec.Having); err != nil {
return err
}
if err := refuseBucketField(spec.BucketOptions, "a formula"); err != nil {
return err
}
if spec.Disabled {
continue
}
@@ -901,15 +907,26 @@ func (r *QueryRangeRequest) validateHeatmap() error {
continue
}
enabled++
// An AI query decodes to the traces spec, so it lands here too. Admitting
// either signal means capping Aggregations at one: each carries its own
// Meta.Buckets, and a heatmap renders against a single bucket axis.
case QueryBuilderQuery[LogAggregation], QueryBuilderQuery[TraceAggregation]:
return errors.New(errors.TypeUnsupported, errors.CodeUnsupported,
"heatmaps are not supported for the logs and traces signals yet")
// An AI query decodes to the traces spec, so it lands here too.
case QueryBuilderQuery[LogAggregation]:
if err := validateEventHeatmapQuery(spec.Functions, spec.Having, spec.BucketOptions, len(spec.Aggregations), spec.Disabled); err != nil {
return err
}
if spec.Disabled {
continue
}
enabled++
case QueryBuilderQuery[TraceAggregation]:
if err := validateEventHeatmapQuery(spec.Functions, spec.Having, spec.BucketOptions, len(spec.Aggregations), spec.Disabled); err != nil {
return err
}
if spec.Disabled {
continue
}
enabled++
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"heatmap requests support one metrics builder query, one formula over them, one clickhouse query, or one promql query, got %q", envelope.Type.StringValue())
"heatmap requests support one builder query, one formula over them, one clickhouse query, or one promql query, got %q", envelope.Type.StringValue())
}
}
@@ -1006,12 +1023,53 @@ func validateHeatmapQuery(functions []Function, having *Having) error {
if having != nil && having.Expression != "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"having is not supported for heatmap requests: it filters individual cells, which breaks the cumulative differencing")
"having is not supported for heatmap requests: it filters individual cells rather than groups, leaving holes in the bucket axis")
}
return nil
}
// validateEventHeatmapQuery holds a logs or traces query to what a per-event
// heatmap can draw. A disabled query feeds a formula rather than the chart, so
// it states no axis of its own and only the settings that still reach the cells
// are checked.
func validateEventHeatmapQuery(functions []Function, having *Having, bucketOptions *BucketOptions, numAggregations int, disabled bool) error {
if err := validateHeatmapQuery(functions, having); err != nil {
return err
}
if disabled {
return nil
}
// Every aggregation carries its own Meta.Buckets, and a heatmap renders
// against a single bucket axis.
if numAggregations > 1 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"a heatmap renders one bucket axis, but the query has %d aggregations", numAggregations).
WithAdditional("Keep the one aggregation whose distribution you want to plot")
}
if bucketOptions == nil || bucketOptions.Field == nil || bucketOptions.Field.Name == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"a logs or traces heatmap needs bucketOptions.field, the numeric field its axis is cut from").
WithAdditional("Set bucketOptions.field to a field such as duration_nano")
}
return nil
}
// refuseBucketField rejects an axis field on a query that has exactly one value
// per point, where naming a second one says the caller expected a different
// chart than the one they would get.
func refuseBucketField(bucketOptions *BucketOptions, subject string) error {
if bucketOptions == nil || bucketOptions.Field == nil {
return nil
}
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"bucketOptions.field is not supported for %s: its axis is cut from the value it already computes", subject)
}
func GetValidationOptions(requestType RequestType) []ValidationOption {
switch requestType {
case RequestTypeTimeSeries, RequestTypeHeatmap:

View File

@@ -34,7 +34,7 @@ export const PATH = {
k8s: '/infrastructure-monitoring/kubernetes',
login: '/login',
metricsSummary: '/metrics-explorer/summary',
metricsViews: '/metrics-explorer/views',
metricsExplorer: '/metrics-explorer/explorer',
roles: '/settings/roles',
roleCreate: '/settings/roles/new',
services: '/services',

View File

@@ -11,13 +11,13 @@ test.describe('Routing — route matching', () => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
await routing.routeTab(PATH.metricsViews).click();
await routing.waitForPath(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
await routing.routeTab(PATH.metricsExplorer).click();
await routing.waitForPath(PATH.metricsExplorer);
await expect(routing.activeRouteTab(PATH.metricsExplorer)).toBeVisible();
await routing.reload();
expect(routing.url().pathname).toBe(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
expect(routing.url().pathname).toBe(PATH.metricsExplorer);
await expect(routing.activeRouteTab(PATH.metricsExplorer)).toBeVisible();
});
test('TC-10 a prefix-route deep link loads with the right tab selected', async ({