mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-01 14:01:21 +00:00
Compare commits
4 Commits
backup/thr
...
tvats-logs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ee9ab846 | ||
|
|
a2154df336 | ||
|
|
03bdeb9ba4 | ||
|
|
e4e58e2349 |
@@ -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:
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
126
pkg/querybuilder/heatmap.go
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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:
|
||||
|
||||
2
tests/e2e/drivers/routing.ts
generated
2
tests/e2e/drivers/routing.ts
generated
@@ -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',
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user