mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-16 11:20:31 +01:00
Compare commits
2 Commits
issue-5121
...
issue-4293
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e52a79a57 | ||
|
|
2522fe0bcf |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
- rootuser
|
||||
- serviceaccount
|
||||
- querier_json_body
|
||||
- promqlparity
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listHosts } from 'api/generated/services/inframonitoring';
|
||||
import {
|
||||
InframonitoringtypesHostRecordDTO,
|
||||
InframonitoringtypesHostStatusDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -126,12 +122,13 @@ function Hosts(): JSX.Element {
|
||||
endTimeBeforeRetention: data.endTimeBeforeRetention,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch hosts';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesHostRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -144,7 +141,7 @@ function Hosts(): JSX.Element {
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesHostRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listHosts(
|
||||
@@ -162,10 +159,11 @@ function Hosts(): JSX.Element {
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch host';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ function ColumnHeader({
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
|
||||
@@ -30,7 +30,7 @@ function EntityGroupHeader({
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
|
||||
@@ -12,8 +12,6 @@ import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -65,10 +63,6 @@ import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import '../EntityDetailsUtils/entityDetails.styles.scss';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import {
|
||||
EntityCountConfig,
|
||||
EntityCountsSection,
|
||||
} from './components/EntityCountsSection/EntityCountsSection';
|
||||
|
||||
const TimeRangeOffset = 1000000000;
|
||||
|
||||
@@ -78,8 +72,6 @@ export interface K8sDetailsMetadataConfig<T> {
|
||||
render?: (value: string | number, entity: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export type K8sDetailsCountConfig<T> = EntityCountConfig<T>;
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filter: { expression: string };
|
||||
start: number;
|
||||
@@ -94,18 +86,15 @@ export interface K8sBaseDetailsProps<T> {
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: APIError | null }>;
|
||||
) => Promise<{ data: T | null; error?: string | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesExpression: (entity: T) => string;
|
||||
getInitialEventsExpression: (entity: T) => string;
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
countsConfig?: K8sDetailsCountConfig<T>[];
|
||||
getCountsFilterExpression?: (entity: T) => string;
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
entity: T,
|
||||
@@ -148,8 +137,6 @@ export default function K8sBaseDetails<T>({
|
||||
getInitialLogTracesExpression,
|
||||
getInitialEventsExpression,
|
||||
metadataConfig,
|
||||
countsConfig,
|
||||
getCountsFilterExpression,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKeyPrefix,
|
||||
@@ -449,18 +436,12 @@ export default function K8sBaseDetails<T>({
|
||||
{isEntityLoading && <LoadingContainer />}
|
||||
{(isEntityError || hasResponseError) && (
|
||||
<div className="entity-error-container">
|
||||
<ErrorContent
|
||||
error={
|
||||
entityResponse?.error ??
|
||||
(entityError instanceof APIError ? entityError : null) ?? {
|
||||
code: 500,
|
||||
message:
|
||||
entityError instanceof Error
|
||||
? entityError.message
|
||||
: 'Failed to load entity details',
|
||||
}
|
||||
}
|
||||
/>
|
||||
<Typography.Text color="danger">
|
||||
{entityResponse?.error ||
|
||||
(entityError instanceof Error
|
||||
? entityError.message
|
||||
: 'Failed to load entity details')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{entity && !isEntityLoading && !hasResponseError && (
|
||||
@@ -498,19 +479,6 @@ export default function K8sBaseDetails<T>({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{countsConfig &&
|
||||
countsConfig.length > 0 &&
|
||||
selectedItem &&
|
||||
getCountsFilterExpression && (
|
||||
<EntityCountsSection
|
||||
entity={entity}
|
||||
countsConfig={countsConfig}
|
||||
selectedItem={selectedItem}
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
@@ -34,14 +33,13 @@ import K8sHeader from './K8sHeader';
|
||||
import { K8sPaginationWarning } from './K8sPaginationWarning';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { getGroupedByMeta } from './utils';
|
||||
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
|
||||
|
||||
import styles from './K8sBaseList.module.scss';
|
||||
import cx from 'classnames';
|
||||
|
||||
export type K8sBaseListEmptyStateContext = {
|
||||
isError: boolean;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
totalCount: number;
|
||||
hasFilters: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -68,7 +66,7 @@ export type K8sBaseListProps<
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total: number;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO | null;
|
||||
@@ -379,8 +377,6 @@ export function K8sBaseList<
|
||||
cancelQuery={cancelQuery}
|
||||
/>
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
<K8sInstrumentationChecksCallout entity={entity} />
|
||||
|
||||
{isError && (
|
||||
<Typography>
|
||||
{data?.error?.toString() || 'Something went wrong'}
|
||||
|
||||
@@ -44,11 +44,13 @@
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
max-width: 500px;
|
||||
padding: 24px;
|
||||
.errorIcon {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { LifeBuoy, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
|
||||
@@ -9,12 +13,26 @@ import styles from './K8sEmptyState.module.scss';
|
||||
|
||||
type K8sEmptyStateProps = Partial<K8sBaseListEmptyStateContext>;
|
||||
|
||||
const handleContactSupport = (isCloudUser: boolean): void => {
|
||||
if (isCloudUser) {
|
||||
history.push('/support');
|
||||
} else {
|
||||
window.open('https://signoz.io/slack', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
export function K8sEmptyState({
|
||||
isError,
|
||||
error,
|
||||
isLoading,
|
||||
endTimeBeforeRetention,
|
||||
}: K8sEmptyStateProps): JSX.Element | null {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const handleSupport = useCallback(() => {
|
||||
handleContactSupport(isCloudUser);
|
||||
}, [isCloudUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
@@ -22,15 +40,25 @@ export function K8sEmptyState({
|
||||
if (isError || error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.errorContent}>
|
||||
<ErrorContent
|
||||
error={
|
||||
error ?? {
|
||||
code: 500,
|
||||
message: 'An error occurred while fetching data.',
|
||||
}
|
||||
}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<TriangleAlert size={32} className={styles.errorIcon} />
|
||||
<span className={styles.message}>
|
||||
{error || 'An error occurred while fetching data.'}
|
||||
</span>
|
||||
<p>
|
||||
Our team is getting on top to resolve this. Please reach out to support if
|
||||
the issue persists.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
onClick={handleSupport}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLocation } from 'react-router-dom';
|
||||
import { Querybuildertypesv5QueryWarnDataDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import APIError from 'types/api/error';
|
||||
import TanStackTable, {
|
||||
SortState,
|
||||
TableColumnDef,
|
||||
@@ -52,7 +51,7 @@ export type K8sExpandedRowProps<T, TItemKey = string> = {
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total: number;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO | null;
|
||||
}>;
|
||||
|
||||
@@ -9,8 +9,6 @@ import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import APIError from 'types/api/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
@@ -633,15 +630,7 @@ describe('K8sBaseList', () => {
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: new APIError({
|
||||
httpStatusCode: 500,
|
||||
error: {
|
||||
code: '500',
|
||||
message: 'Failed to fetch pods',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
}),
|
||||
error: 'Failed to fetch pods',
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
@@ -1065,304 +1054,4 @@ describe('K8sBaseList', () => {
|
||||
expect(url).not.toContain('selectedItemNamespaceName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('instrumentation checks callout', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: 'item-1' }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render callout when ready is true', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: true,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('item-1');
|
||||
|
||||
expect(screen.queryByText('Instrumentation checks')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render callout when no entries exist', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: null,
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: null,
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('item-1');
|
||||
|
||||
expect(screen.queryByText('Instrumentation checks')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render callout with present entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage', 'k8s.pod.memory.usage'],
|
||||
},
|
||||
],
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: null,
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Default enabled metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.usage, k8s.pod.memory.usage'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('otel-collector'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render callout with missing entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: null,
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: null,
|
||||
missingDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.limit'],
|
||||
documentationLink: 'https://example.com/docs',
|
||||
},
|
||||
],
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
await expect(
|
||||
screen.findByText('Missing default metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.limit'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Learn here')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should trigger recheck on button click', async () => {
|
||||
let recheckCallCount = 0;
|
||||
server.use(
|
||||
rest.get(
|
||||
'http://localhost/api/v2/infra_monitoring/checks',
|
||||
(_, res, ctx) => {
|
||||
recheckCallCount++;
|
||||
return res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
const initialCallCount = recheckCallCount;
|
||||
|
||||
const recheckBtn = screen.getByTestId('instrumentation-checks-recheck-btn');
|
||||
await user.click(recheckBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(recheckCallCount).toBeGreaterThan(initialCallCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render both present and missing entries', async () => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v2/infra_monitoring/checks', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
ready: false,
|
||||
type: 'pods',
|
||||
presentDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.cpu.usage'],
|
||||
},
|
||||
],
|
||||
presentOptionalMetrics: null,
|
||||
presentRequiredAttributes: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
attributes: ['k8s.namespace.name'],
|
||||
},
|
||||
],
|
||||
missingDefaultEnabledMetrics: [
|
||||
{
|
||||
associatedComponent: { name: 'otel-collector' },
|
||||
metrics: ['k8s.pod.memory.limit'],
|
||||
},
|
||||
],
|
||||
missingOptionalMetrics: null,
|
||||
missingRequiredAttributes: null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await screen.findByText('Instrumentation checks');
|
||||
|
||||
// Present entries
|
||||
await expect(
|
||||
screen.findByText('Default enabled metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.cpu.usage'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Required attributes'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.namespace.name'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// Missing entries
|
||||
await expect(
|
||||
screen.findByText('Missing default metrics'),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('k8s.pod.memory.limit'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
.countsContainer {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
margin-top: var(--spacing-8);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.countCard {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 180px;
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
color: var(--l2-foreground);
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.countValue {
|
||||
color: var(--l1-foreground);
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.navigateButton {
|
||||
position: absolute;
|
||||
top: var(--spacing-4);
|
||||
right: var(--spacing-4);
|
||||
|
||||
--button-padding: var(--spacing-1);
|
||||
--button-height: 24px;
|
||||
--button-width: 24px;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../../constants';
|
||||
import styles from './EntityCountsSection.module.scss';
|
||||
|
||||
export interface EntityCountConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => number;
|
||||
targetCategory: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
interface EntityCountsSectionProps<T> {
|
||||
entity: T;
|
||||
countsConfig: EntityCountConfig<T>[];
|
||||
selectedItem: string;
|
||||
filterExpression: string;
|
||||
closeDrawer: () => void;
|
||||
}
|
||||
|
||||
export function EntityCountsSection<T>({
|
||||
entity,
|
||||
countsConfig,
|
||||
selectedItem,
|
||||
filterExpression,
|
||||
closeDrawer,
|
||||
}: EntityCountsSectionProps<T>): JSX.Element {
|
||||
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const compositeQuery = {
|
||||
...defaultQuery,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: filterExpression },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
// TODO(H4ad): After https://github.com/SigNoz/signoz/pull/12038, inherit custom time of drawer to list
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.countsContainer}>
|
||||
{countsConfig.map((config) => (
|
||||
<div
|
||||
key={config.label}
|
||||
className={styles.countCard}
|
||||
data-testid={`count-card-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
size="small"
|
||||
weight="medium"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.countValue} size="xl" weight="semibold">
|
||||
{config.getValue(entity) || '-'}
|
||||
</Typography.Text>
|
||||
<Link
|
||||
to={buildNavigationUrl(config.targetCategory)}
|
||||
onClick={closeDrawer}
|
||||
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
<Tooltip
|
||||
title={`View ${config.label.toLowerCase()} of '${selectedItem}'`}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.navigateButton}
|
||||
prefix={<Compass size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
.checkContainer {
|
||||
padding: 0px var(--spacing-4) var(--spacing-4) var(--spacing-4);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entriesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { MouseEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { RefreshCw } from '@signozhq/icons';
|
||||
import setLocalStorage from 'api/browser/localstorage/set';
|
||||
import {
|
||||
invalidateGetChecks,
|
||||
useGetChecks,
|
||||
} from 'api/generated/services/inframonitoring';
|
||||
import { InframonitoringtypesCheckTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
|
||||
import styles from './K8sInstrumentationChecksCallout.module.scss';
|
||||
import { MissingEntryRow } from './MissingEntryRow';
|
||||
import { PresentEntryRow } from './PresentEntryRow';
|
||||
import {
|
||||
ENTITY_TO_CHECK_TYPE,
|
||||
getStorageKey,
|
||||
hasAnyEntries,
|
||||
hasMissingEntries,
|
||||
readExpandedState,
|
||||
} from './utils';
|
||||
|
||||
export interface InstrumentationChecksCalloutProps {
|
||||
entity: InfraMonitoringEntity;
|
||||
}
|
||||
|
||||
export function K8sInstrumentationChecksCallout({
|
||||
entity,
|
||||
}: InstrumentationChecksCalloutProps): JSX.Element | null {
|
||||
const checkType = ENTITY_TO_CHECK_TYPE[entity];
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(() => readExpandedState(entity));
|
||||
|
||||
const { data, isLoading, isFetching } = useGetChecks(
|
||||
{ type: checkType as InframonitoringtypesCheckTypeDTO },
|
||||
{ query: { enabled: !!checkType } },
|
||||
);
|
||||
|
||||
const handleRecheck = useCallback(
|
||||
(e: MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (checkType) {
|
||||
void invalidateGetChecks(queryClient, { type: checkType });
|
||||
}
|
||||
},
|
||||
[queryClient, checkType],
|
||||
);
|
||||
|
||||
const handleExpandToggle = useCallback((): void => {
|
||||
setIsExpanded((prev) => {
|
||||
const next = !prev;
|
||||
setLocalStorage(getStorageKey(entity), String(next));
|
||||
return next;
|
||||
});
|
||||
}, [entity]);
|
||||
|
||||
const checksData = data?.data;
|
||||
|
||||
const hasMissingItems = useMemo(
|
||||
() => (checksData ? hasMissingEntries(checksData) : false),
|
||||
[checksData],
|
||||
);
|
||||
|
||||
if (!checkType || isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!checksData || checksData.ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasAnyEntries(checksData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.checkContainer}>
|
||||
<Callout
|
||||
type={hasMissingItems ? 'warning' : 'info'}
|
||||
showIcon
|
||||
size="medium"
|
||||
title={
|
||||
<div className={styles.header}>
|
||||
Instrumentation checks
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
size="sm"
|
||||
onClick={handleRecheck}
|
||||
loading={isFetching}
|
||||
prefix={<RefreshCw size={12} />}
|
||||
data-testid="instrumentation-checks-recheck-btn"
|
||||
>
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
action="expandable"
|
||||
defaultExpanded={isExpanded}
|
||||
onClick={handleExpandToggle}
|
||||
>
|
||||
<div className={styles.container} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.entriesList}>
|
||||
{checksData.presentDefaultEnabledMetrics?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-default-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Default enabled metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.presentOptionalMetrics?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-optional-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Optional metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.presentRequiredAttributes?.map((entry) => (
|
||||
<PresentEntryRow
|
||||
key={`present-attrs-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Required attributes"
|
||||
itemType="attributes"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingDefaultEnabledMetrics?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-default-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing default metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingOptionalMetrics?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-optional-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing optional metrics"
|
||||
itemType="metrics"
|
||||
/>
|
||||
))}
|
||||
{checksData.missingRequiredAttributes?.map((entry) => (
|
||||
<MissingEntryRow
|
||||
key={`missing-attrs-${entry.associatedComponent.name}`}
|
||||
entry={entry}
|
||||
typeLabel="Missing required attributes"
|
||||
itemType="attributes"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
.entryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.missingIcon {
|
||||
color: var(--warning-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.learnMoreLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
|
||||
svg {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--callout-warning-border);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import styles from './MissingEntryRow.module.scss';
|
||||
import { ExternalLink, TriangleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import type {
|
||||
InframonitoringtypesMissingAttributesComponentEntryDTO,
|
||||
InframonitoringtypesMissingMetricsComponentEntryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
type MissingEntryRowProps =
|
||||
| {
|
||||
entry: InframonitoringtypesMissingMetricsComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'metrics';
|
||||
}
|
||||
| {
|
||||
entry: InframonitoringtypesMissingAttributesComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'attributes';
|
||||
};
|
||||
|
||||
export function MissingEntryRow({
|
||||
entry,
|
||||
typeLabel,
|
||||
itemType,
|
||||
}: MissingEntryRowProps): JSX.Element {
|
||||
const items = itemType === 'metrics' ? entry.metrics : entry.attributes;
|
||||
|
||||
return (
|
||||
<div className={styles.entryRow}>
|
||||
<TriangleAlert size={14} className={styles.missingIcon} />
|
||||
<Typography.Text size="base" weight="medium">
|
||||
{typeLabel}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.entryRowMetric}
|
||||
>
|
||||
{items?.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" italic color="muted">
|
||||
{entry.associatedComponent.name}
|
||||
</Typography.Text>
|
||||
{entry.documentationLink && (
|
||||
<Typography.Link
|
||||
href={entry.documentationLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="base"
|
||||
className={styles.learnMoreLink}
|
||||
>
|
||||
Learn here
|
||||
<ExternalLink size={12} />
|
||||
</Typography.Link>
|
||||
)}
|
||||
<Divider className={styles.divider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.entryRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.presentIcon {
|
||||
color: var(--success-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--callout-warning-border);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import styles from './PresentEntryRow.module.scss';
|
||||
import { CircleCheck } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import type {
|
||||
InframonitoringtypesAttributesComponentEntryDTO,
|
||||
InframonitoringtypesMetricsComponentEntryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
type PresentEntryRowProps =
|
||||
| {
|
||||
entry: InframonitoringtypesMetricsComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'metrics';
|
||||
}
|
||||
| {
|
||||
entry: InframonitoringtypesAttributesComponentEntryDTO;
|
||||
typeLabel: string;
|
||||
itemType: 'attributes';
|
||||
};
|
||||
|
||||
export function PresentEntryRow({
|
||||
entry,
|
||||
typeLabel,
|
||||
itemType,
|
||||
}: PresentEntryRowProps): JSX.Element {
|
||||
const items = itemType === 'metrics' ? entry.metrics : entry.attributes;
|
||||
|
||||
return (
|
||||
<div className={styles.entryRow}>
|
||||
<CircleCheck size={14} className={styles.presentIcon} />
|
||||
<Typography.Text size="base" weight="medium">
|
||||
{typeLabel}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
size="base"
|
||||
weight="semibold"
|
||||
className={styles.entryRowMetric}
|
||||
>
|
||||
{items?.join(', ')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" color="muted">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Typography.Text size="base" italic color="muted">
|
||||
{entry.associatedComponent.name}
|
||||
</Typography.Text>
|
||||
<Divider className={styles.divider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import getLocalStorage from 'api/browser/localstorage/get';
|
||||
import {
|
||||
InframonitoringtypesChecksDTO,
|
||||
InframonitoringtypesCheckTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
|
||||
export const ENTITY_TO_CHECK_TYPE: Record<
|
||||
InfraMonitoringEntity,
|
||||
InframonitoringtypesCheckTypeDTO
|
||||
> = {
|
||||
[InfraMonitoringEntity.HOSTS]: InframonitoringtypesCheckTypeDTO.hosts,
|
||||
[InfraMonitoringEntity.PODS]: InframonitoringtypesCheckTypeDTO.pods,
|
||||
[InfraMonitoringEntity.NODES]: InframonitoringtypesCheckTypeDTO.nodes,
|
||||
[InfraMonitoringEntity.NAMESPACES]:
|
||||
InframonitoringtypesCheckTypeDTO.namespaces,
|
||||
[InfraMonitoringEntity.CLUSTERS]: InframonitoringtypesCheckTypeDTO.clusters,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]:
|
||||
InframonitoringtypesCheckTypeDTO.deployments,
|
||||
[InfraMonitoringEntity.STATEFULSETS]:
|
||||
InframonitoringtypesCheckTypeDTO.statefulsets,
|
||||
[InfraMonitoringEntity.DAEMONSETS]:
|
||||
InframonitoringtypesCheckTypeDTO.daemonsets,
|
||||
[InfraMonitoringEntity.JOBS]: InframonitoringtypesCheckTypeDTO.jobs,
|
||||
[InfraMonitoringEntity.VOLUMES]: InframonitoringtypesCheckTypeDTO.volumes,
|
||||
[InfraMonitoringEntity.CONTAINERS]:
|
||||
InframonitoringtypesCheckTypeDTO.kube_containers,
|
||||
};
|
||||
|
||||
export function hasMissingEntries(
|
||||
data: InframonitoringtypesChecksDTO,
|
||||
): boolean {
|
||||
return (
|
||||
(data.missingDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingRequiredAttributes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAnyEntries(data: InframonitoringtypesChecksDTO): boolean {
|
||||
return (
|
||||
(data.presentDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.presentOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.presentRequiredAttributes?.length ?? 0) > 0 ||
|
||||
(data.missingDefaultEnabledMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingOptionalMetrics?.length ?? 0) > 0 ||
|
||||
(data.missingRequiredAttributes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = '@signozhq/k8s-instrumentation-checks-expanded';
|
||||
|
||||
export function getStorageKey(entity: InfraMonitoringEntity): string {
|
||||
return `${STORAGE_PREFIX}-${entity}`;
|
||||
}
|
||||
|
||||
export function readExpandedState(entity: InfraMonitoringEntity): boolean {
|
||||
const stored = getLocalStorage(getStorageKey(entity));
|
||||
return stored === null ? true : stored === 'true';
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listClusters } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesClusterRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -18,9 +14,7 @@ import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
clusterWidgetInfo,
|
||||
getClusterMetricsQueryPayload,
|
||||
k8sClusterDetailsCountsConfig,
|
||||
k8sClusterDetailsMetadataConfig,
|
||||
k8sClusterGetCountsFilterExpression,
|
||||
k8sClusterGetEntityName,
|
||||
k8sClusterGetSelectedItemExpression,
|
||||
k8sClusterInitialEventsExpression,
|
||||
@@ -73,12 +67,13 @@ function K8sClustersList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch clusters';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesClusterRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -91,7 +86,7 @@ function K8sClustersList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesClusterRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listClusters(
|
||||
@@ -109,10 +104,11 @@ function K8sClustersList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch cluster';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -140,8 +136,6 @@ function K8sClustersList({
|
||||
getInitialLogTracesExpression={k8sClusterInitialLogTracesExpression}
|
||||
getInitialEventsExpression={k8sClusterInitialEventsExpression}
|
||||
metadataConfig={k8sClusterDetailsMetadataConfig}
|
||||
countsConfig={k8sClusterDetailsCountsConfig}
|
||||
getCountsFilterExpression={k8sClusterGetCountsFilterExpression}
|
||||
entityWidgetInfo={clusterWidgetInfo}
|
||||
getEntityQueryPayload={getClusterMetricsQueryPayload}
|
||||
queryKeyPrefix="cluster"
|
||||
|
||||
@@ -6,15 +6,9 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
@@ -29,40 +23,6 @@ export const k8sClusterGetSelectedItemExpression = (
|
||||
export const k8sClusterDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesClusterRecordDTO>[] =
|
||||
[{ label: 'Cluster Name', getValue: (p): string => p.clusterName || '' }];
|
||||
|
||||
export const k8sClusterDetailsCountsConfig: K8sDetailsCountConfig<InframonitoringtypesClusterRecordDTO>[] =
|
||||
[
|
||||
{
|
||||
label: 'Namespaces',
|
||||
getValue: (p): number => p.counts?.namespaces ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.NAMESPACES,
|
||||
},
|
||||
{
|
||||
label: 'Nodes',
|
||||
getValue: (p): number => p.counts?.nodes ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.NODES,
|
||||
},
|
||||
{
|
||||
label: 'Deployments',
|
||||
getValue: (p): number => p.counts?.deployments ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
},
|
||||
{
|
||||
label: 'StatefulSets',
|
||||
getValue: (p): number => p.counts?.statefulSets ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.STATEFULSETS,
|
||||
},
|
||||
{
|
||||
label: 'DaemonSets',
|
||||
getValue: (p): number => p.counts?.daemonSets ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.DAEMONSETS,
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
getValue: (p): number => p.counts?.jobs ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.JOBS,
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sClusterInitialEventsExpression = (
|
||||
item: InframonitoringtypesClusterRecordDTO,
|
||||
): string =>
|
||||
@@ -83,54 +43,38 @@ export const k8sClusterGetEntityName = (
|
||||
item: InframonitoringtypesClusterRecordDTO,
|
||||
): string => item.clusterName || '';
|
||||
|
||||
export const k8sClusterGetCountsFilterExpression = (
|
||||
item: InframonitoringtypesClusterRecordDTO,
|
||||
): string =>
|
||||
`k8s.cluster.name = ${formatValueForExpression(item.clusterName ?? '')}`;
|
||||
|
||||
export const clusterWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage, allocatable',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#cpu-usage-allocatable',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage, allocatable',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#memory-usage-allocatable',
|
||||
},
|
||||
{
|
||||
title: 'Ready Nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#ready-nodes',
|
||||
},
|
||||
{
|
||||
title: 'NotReady Nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#notready-nodes',
|
||||
},
|
||||
{
|
||||
title: 'Deployments available and desired',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/clusters/#deployments-available-and-desired',
|
||||
},
|
||||
{
|
||||
title: 'Statefulset pods',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#statefulset-pods',
|
||||
},
|
||||
{
|
||||
title: 'Daemonset nodes',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#daemonset-nodes',
|
||||
},
|
||||
{
|
||||
title: 'Jobs',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/clusters/#jobs',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listDaemonSets } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesDaemonSetRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
@@ -69,12 +65,13 @@ function K8sDaemonSetsList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch daemonsets';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesDaemonSetRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -86,7 +83,7 @@ function K8sDaemonSetsList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesDaemonSetRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listDaemonSets(
|
||||
@@ -103,10 +100,11 @@ function K8sDaemonSetsList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch daemonset';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,25 +71,18 @@ export const daemonSetWidgetInfo = [
|
||||
{
|
||||
title: 'CPU usage, request, limits',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#cpu-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#memory-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/daemonsets/#network-io',
|
||||
},
|
||||
{
|
||||
title: 'Network errors count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/daemonsets/#network-errors-count',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -140,10 +140,10 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'scheduled_nodes',
|
||||
id: 'node_status',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#scheduled-nodes">
|
||||
Scheduled Nodes
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/daemonsets#node-status">
|
||||
Node Status
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.currentNodes,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listDeployments } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesDeploymentRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -72,12 +68,13 @@ function K8sDeploymentsList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch deployments';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesDeploymentRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -90,7 +87,7 @@ function K8sDeploymentsList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesDeploymentRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listDeployments(
|
||||
@@ -108,10 +105,11 @@ function K8sDeploymentsList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch deployment';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -71,25 +71,18 @@ export const deploymentWidgetInfo = [
|
||||
{
|
||||
title: 'CPU usage, request, limits',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#cpu-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#memory-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/deployments/#network-io',
|
||||
},
|
||||
{
|
||||
title: 'Network error count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/deployments/#network-error-count',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -133,10 +133,10 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pod_replicas',
|
||||
id: 'replica_status',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#pod-replicas">
|
||||
Pod Replicas
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/deployments#replica-status">
|
||||
Replica Status
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.availablePods,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
.chartHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-slate-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.metricsExplorerLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.chartHeaderLabel {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Compass, Info } from '@signozhq/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
import styles from './ChartHeader.module.scss';
|
||||
|
||||
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
|
||||
|
||||
interface ChartHeaderProps {
|
||||
title: string;
|
||||
docPath?: string;
|
||||
tooltip?: string;
|
||||
metricsExplorerUrl?: string;
|
||||
metricsExplorerTestId?: string;
|
||||
}
|
||||
|
||||
function ChartHeader({
|
||||
title,
|
||||
docPath,
|
||||
tooltip,
|
||||
metricsExplorerUrl,
|
||||
metricsExplorerTestId = 'open-metrics-explorer',
|
||||
}: ChartHeaderProps): JSX.Element {
|
||||
const renderInfoIcon = (): React.ReactNode => {
|
||||
if (docPath) {
|
||||
const tooltipTitle = tooltip || 'Not sure what this represents?';
|
||||
return (
|
||||
<Tooltip
|
||||
arrow
|
||||
title={
|
||||
<>
|
||||
{tooltipTitle}{' '}
|
||||
<a
|
||||
href={`${DOCS_BASE_URL}${docPath}`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn more.
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
|
||||
<Info size="md" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span className={styles.infoIcon} data-testid="chart-header-info-icon">
|
||||
<Info size="md" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.chartHeader} data-testid="chart-header">
|
||||
<span className={styles.chartHeaderLabel}>{title}</span>
|
||||
{renderInfoIcon()}
|
||||
{metricsExplorerUrl && (
|
||||
<Tooltip title="Open in Metrics Explorer">
|
||||
<Link
|
||||
to={metricsExplorerUrl}
|
||||
className={styles.metricsExplorerLink}
|
||||
data-testid={metricsExplorerTestId}
|
||||
>
|
||||
<Compass size={14} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChartHeader;
|
||||
@@ -14,6 +14,28 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.entityMetricsTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entityMetricsTitle {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.metricsExplorerLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.metricsHeader {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
@@ -22,7 +24,6 @@ import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { getMetricsExplorerUrl } from 'utils/explorerUtils';
|
||||
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
import ChartHeader from './ChartHeader';
|
||||
|
||||
import { useEntityMetrics } from './hooks';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
@@ -46,7 +47,6 @@ interface EntityMetricsProps<T> {
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
docPath?: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
node: T,
|
||||
@@ -207,24 +207,31 @@ function EntityMetrics<T>({
|
||||
key={entityWidgetInfo[idx].title}
|
||||
className={styles.entityMetricsCol}
|
||||
>
|
||||
<ChartHeader
|
||||
title={entityWidgetInfo[idx].title}
|
||||
docPath={entityWidgetInfo[idx].docPath}
|
||||
metricsExplorerUrl={
|
||||
queryPayloads[idx] && queryPayloads[idx].graphType !== PANEL_TYPES.TABLE
|
||||
? getMetricsExplorerUrl({
|
||||
query: queryPayloads[idx].query,
|
||||
...(selectedInterval && selectedInterval !== 'custom'
|
||||
? { relativeTime: selectedInterval }
|
||||
: {
|
||||
startTimeMs: timeRange.startTime * 1000,
|
||||
endTimeMs: timeRange.endTime * 1000,
|
||||
}),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
metricsExplorerTestId={`open-metrics-explorer-${idx}`}
|
||||
/>
|
||||
<div className={styles.entityMetricsTitleContainer}>
|
||||
<span className={styles.entityMetricsTitle}>
|
||||
{entityWidgetInfo[idx].title}
|
||||
</span>
|
||||
{queryPayloads[idx] &&
|
||||
queryPayloads[idx].graphType !== PANEL_TYPES.TABLE && (
|
||||
<Tooltip title="Open in Metrics Explorer">
|
||||
<Link
|
||||
to={getMetricsExplorerUrl({
|
||||
query: queryPayloads[idx].query,
|
||||
...(selectedInterval && selectedInterval !== 'custom'
|
||||
? { relativeTime: selectedInterval }
|
||||
: {
|
||||
startTimeMs: timeRange.startTime * 1000,
|
||||
endTimeMs: timeRange.endTime * 1000,
|
||||
}),
|
||||
})}
|
||||
className={styles.metricsExplorerLink}
|
||||
data-testid={`open-metrics-explorer-${idx}`}
|
||||
>
|
||||
<Compass size={14} />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.entityMetricsCard} ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listJobs } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesJobRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -72,12 +68,13 @@ function K8sJobsList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch jobs';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesJobRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -90,7 +87,7 @@ function K8sJobsList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesJobRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listJobs(
|
||||
@@ -108,10 +105,11 @@ function K8sJobsList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch job';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,22 +70,18 @@ export const jobWidgetInfo = [
|
||||
{
|
||||
title: 'CPU usage',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#cpu-usage',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#memory-usage',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#network-io',
|
||||
},
|
||||
{
|
||||
title: 'Network errors count',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/jobs/#network-errors-count',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -132,10 +132,10 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'completion',
|
||||
id: 'completion_status',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#completion">
|
||||
Completions
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/jobs#completion-status">
|
||||
Completion Status
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.successfulPods,
|
||||
@@ -155,7 +155,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
|
||||
{
|
||||
value: row.desiredSuccessfulPods,
|
||||
label: 'Desired',
|
||||
color: Color.BG_AMBER_500,
|
||||
color: Color.BG_ROBIN_500,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listNamespaces } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesNamespaceRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -18,9 +14,7 @@ import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
getNamespaceMetricsQueryPayload,
|
||||
k8sNamespaceDetailsCountsConfig,
|
||||
k8sNamespaceDetailsMetadataConfig,
|
||||
k8sNamespaceGetCountsFilterExpression,
|
||||
k8sNamespaceGetEntityName,
|
||||
k8sNamespaceGetSelectedItemExpression,
|
||||
k8sNamespaceInitialEventsExpression,
|
||||
@@ -74,12 +68,13 @@ function K8sNamespacesList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch namespaces';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesNamespaceRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -92,7 +87,7 @@ function K8sNamespacesList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesNamespaceRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listNamespaces(
|
||||
@@ -110,10 +105,11 @@ function K8sNamespacesList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch namespace';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -141,8 +137,6 @@ function K8sNamespacesList({
|
||||
getInitialLogTracesExpression={k8sNamespaceInitialLogTracesExpression}
|
||||
getInitialEventsExpression={k8sNamespaceInitialEventsExpression}
|
||||
metadataConfig={k8sNamespaceDetailsMetadataConfig}
|
||||
countsConfig={k8sNamespaceDetailsCountsConfig}
|
||||
getCountsFilterExpression={k8sNamespaceGetCountsFilterExpression}
|
||||
entityWidgetInfo={namespaceWidgetInfo}
|
||||
getEntityQueryPayload={getNamespaceMetricsQueryPayload}
|
||||
queryKeyPrefix="namespace"
|
||||
|
||||
@@ -6,16 +6,9 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
buildExpressionFromSelectedItemParams,
|
||||
@@ -40,30 +33,6 @@ export const k8sNamespaceDetailsMetadataConfig: K8sDetailsMetadataConfig<Inframo
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sNamespaceDetailsCountsConfig: K8sDetailsCountConfig<InframonitoringtypesNamespaceRecordDTO>[] =
|
||||
[
|
||||
{
|
||||
label: 'Deployments',
|
||||
getValue: (p): number => p.counts?.deployments ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
},
|
||||
{
|
||||
label: 'StatefulSets',
|
||||
getValue: (p): number => p.counts?.statefulSets ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.STATEFULSETS,
|
||||
},
|
||||
{
|
||||
label: 'DaemonSets',
|
||||
getValue: (p): number => p.counts?.daemonSets ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.DAEMONSETS,
|
||||
},
|
||||
{
|
||||
label: 'Jobs',
|
||||
getValue: (p): number => p.counts?.jobs ?? 0,
|
||||
targetCategory: InfraMonitoringEntity.JOBS,
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sNamespaceInitialEventsExpression = (
|
||||
item: InframonitoringtypesNamespaceRecordDTO,
|
||||
): string =>
|
||||
@@ -86,78 +55,46 @@ export const k8sNamespaceGetEntityName = (
|
||||
item: InframonitoringtypesNamespaceRecordDTO,
|
||||
): string => item.namespaceName || '';
|
||||
|
||||
export const k8sNamespaceGetCountsFilterExpression = (
|
||||
item: InframonitoringtypesNamespaceRecordDTO,
|
||||
): string => {
|
||||
const clusterName = item.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME];
|
||||
const clauses: string[] = [];
|
||||
|
||||
if (clusterName) {
|
||||
clauses.push(
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME} = ${formatValueForExpression(clusterName)}`,
|
||||
);
|
||||
}
|
||||
if (item.namespaceName) {
|
||||
clauses.push(
|
||||
`${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME} = ${formatValueForExpression(item.namespaceName)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return clauses.join(' AND ');
|
||||
};
|
||||
|
||||
export const namespaceWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#cpu-usage-cores',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/namespaces/#memory-usage-bytes',
|
||||
},
|
||||
{
|
||||
title: 'Pods CPU (top 10)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#pods-cpu-top-10',
|
||||
},
|
||||
{
|
||||
title: 'Pods Memory (top 10)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/namespaces/#pods-memory-top-10',
|
||||
},
|
||||
{
|
||||
title: 'Network rate',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#network-rate',
|
||||
},
|
||||
{
|
||||
title: 'Network errors',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#network-errors',
|
||||
},
|
||||
{
|
||||
title: 'StatefulSets (pods)',
|
||||
title: 'StatefulSets',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#statefulsets',
|
||||
},
|
||||
{
|
||||
title: 'ReplicaSets (pods)',
|
||||
title: 'ReplicaSets',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#replicasets',
|
||||
},
|
||||
{
|
||||
title: 'DaemonSets (nodes)',
|
||||
title: 'DaemonSets',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#daemonsets',
|
||||
},
|
||||
{
|
||||
title: 'Deployments (pods)',
|
||||
title: 'Deployments',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/namespaces/#deployments',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listNodes } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesNodeRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -71,12 +67,13 @@ function K8sNodesList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch nodes';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesNodeRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -89,7 +86,7 @@ function K8sNodesList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesNodeRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listNodes(
|
||||
@@ -107,10 +104,11 @@ function K8sNodesList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch node';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,53 +57,42 @@ export const nodeWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-cores',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#memory-usage-bytes',
|
||||
},
|
||||
{
|
||||
title: 'CPU Usage (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#cpu-usage-',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#memory-usage-',
|
||||
},
|
||||
{
|
||||
title: 'Pods by CPU (top 10)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#pods-by-cpu-top-10',
|
||||
},
|
||||
{
|
||||
title: 'Pods by Memory (top 10)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#pods-by-memory-top-10',
|
||||
},
|
||||
{
|
||||
title: 'Network error count',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#network-error-count',
|
||||
},
|
||||
{
|
||||
title: 'Network IO rate',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#network-io-rate',
|
||||
},
|
||||
{
|
||||
title: 'Filesystem usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/nodes/#filesystem-usage-bytes',
|
||||
},
|
||||
{
|
||||
title: 'Filesystem usage (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/nodes/#filesystem-usage-',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listPods } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesPodRecordDTO,
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -71,12 +67,13 @@ function K8sPodsList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch pods';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesPodRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -89,7 +86,7 @@ function K8sPodsList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesPodRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listPods(
|
||||
@@ -107,10 +104,11 @@ function K8sPodsList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch pod';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -67,74 +67,54 @@ export const podWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#cpu-usage-cores-1',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#cpu-request-limit-utilization',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#memory-usage-bytes',
|
||||
},
|
||||
{
|
||||
title: 'Memory Request, Limit Utilization',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-request-limit-utilization',
|
||||
},
|
||||
{
|
||||
title: 'Memory by State',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#memory-by-state',
|
||||
},
|
||||
{
|
||||
title: 'Memory Major Page Faults',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-major-page-faults',
|
||||
},
|
||||
{
|
||||
title: 'CPU Usage by Container (cores)',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#cpu-usage-by-container-cores',
|
||||
},
|
||||
{
|
||||
title: 'CPU Request, Limit Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#cpu-request-limit-utilization-by-container',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage by Container (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-usage-by-container-bytes',
|
||||
},
|
||||
{
|
||||
title: 'Memory Request, Limit Utilization by Container',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/pods/#memory-request-limit-utilization-by-container',
|
||||
},
|
||||
{
|
||||
title: 'Network rate',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#network-rate',
|
||||
},
|
||||
{
|
||||
title: 'Network errors',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#network-errors',
|
||||
},
|
||||
{
|
||||
title: 'File system (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/pods/#file-system-bytes',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -152,11 +152,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'podAge',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#age">
|
||||
Age
|
||||
</ColumnHeader>
|
||||
),
|
||||
header: 'Age',
|
||||
accessorFn: (row): number => row.podAge,
|
||||
width: { min: 100 },
|
||||
enableSort: false,
|
||||
@@ -320,11 +316,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'namespace',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#additional-columns">
|
||||
Namespace
|
||||
</ColumnHeader>
|
||||
),
|
||||
header: 'Namespace',
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
|
||||
width: { default: 100 },
|
||||
@@ -336,11 +328,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'node',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#additional-columns">
|
||||
Node
|
||||
</ColumnHeader>
|
||||
),
|
||||
header: 'Node',
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
|
||||
width: { default: 100 },
|
||||
@@ -352,11 +340,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'cluster',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/pods#additional-columns">
|
||||
Cluster
|
||||
</ColumnHeader>
|
||||
),
|
||||
header: 'Cluster',
|
||||
accessorFn: (row): string =>
|
||||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
|
||||
width: { default: 100 },
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listStatefulSets } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
InframonitoringtypesStatefulSetRecordDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -72,12 +68,13 @@ function K8sStatefulSetsList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch statefulsets';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesStatefulSetRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -90,7 +87,7 @@ function K8sStatefulSetsList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesStatefulSetRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listStatefulSets(
|
||||
@@ -108,10 +105,11 @@ function K8sStatefulSetsList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch statefulset';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -72,37 +72,26 @@ export const statefulSetWidgetInfo = [
|
||||
{
|
||||
title: 'CPU usage, request, limits',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#cpu-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'CPU request, limit util (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#cpu-request-limit-utilization-',
|
||||
},
|
||||
{
|
||||
title: 'Memory usage, request, limits',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#memory-usage-request-limits',
|
||||
},
|
||||
{
|
||||
title: 'Memory request, limit util (%)',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#memory-request-limit-utilization-',
|
||||
},
|
||||
{
|
||||
title: 'Network IO',
|
||||
yAxisUnit: 'binBps',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/statefulsets/#network-io',
|
||||
},
|
||||
{
|
||||
title: 'Network errors count',
|
||||
yAxisUnit: '',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/kubernetes/statefulsets/#network-errors-count',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -141,10 +141,10 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pod_replicas',
|
||||
id: 'pod_status',
|
||||
header: (): React.ReactNode => (
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#pod-replicas">
|
||||
Pod Replicas
|
||||
<ColumnHeader docPath="/infrastructure-monitoring/kubernetes/statefulsets#pod-status">
|
||||
Pod Status
|
||||
</ColumnHeader>
|
||||
),
|
||||
accessorFn: (row): number => row.currentPods,
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { listVolumes } from 'api/generated/services/inframonitoring';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import {
|
||||
InframonitoringtypesResponseTypeDTO,
|
||||
InframonitoringtypesVolumeRecordDTO,
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
@@ -72,12 +68,13 @@ function K8sVolumesList({
|
||||
warning: data.warning,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch volumes';
|
||||
return {
|
||||
type: 'list' as const,
|
||||
records: [] as InframonitoringtypesVolumeRecordDTO[],
|
||||
total: 0,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -90,7 +87,7 @@ function K8sVolumesList({
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: InframonitoringtypesVolumeRecordDTO | null;
|
||||
error?: APIError | null;
|
||||
error?: string | null;
|
||||
}> => {
|
||||
try {
|
||||
const response = await listVolumes(
|
||||
@@ -108,10 +105,11 @@ function K8sVolumesList({
|
||||
data: response.data.records.length > 0 ? response.data.records[0] : null,
|
||||
};
|
||||
} catch (error) {
|
||||
const errMsg =
|
||||
error instanceof Error ? error.message : 'Failed to fetch volume';
|
||||
return {
|
||||
data: null,
|
||||
error:
|
||||
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
|
||||
error: errMsg,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -69,27 +69,22 @@ export const volumeWidgetInfo = [
|
||||
{
|
||||
title: 'Volume available',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-available',
|
||||
},
|
||||
{
|
||||
title: 'Volume capacity',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-capacity',
|
||||
},
|
||||
{
|
||||
title: 'Volume inodes used',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-used',
|
||||
},
|
||||
{
|
||||
title: 'Volume inodes',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes',
|
||||
},
|
||||
{
|
||||
title: 'Volume inodes free',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/kubernetes/volumes/#volume-inodes-free',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2872,72 +2872,17 @@ export const nodeWidgetInfo = [
|
||||
];
|
||||
|
||||
export const hostWidgetInfo = [
|
||||
{
|
||||
title: 'CPU Usage',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#cpu-usage',
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#memory-usage',
|
||||
},
|
||||
{
|
||||
title: 'System Load Average',
|
||||
yAxisUnit: '',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#system-load-average',
|
||||
},
|
||||
{
|
||||
title: 'Network usage (bytes)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-bytes',
|
||||
},
|
||||
{
|
||||
title: 'Network usage (packet/s)',
|
||||
yAxisUnit: 'pps',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-usage-packetss',
|
||||
},
|
||||
{
|
||||
title: 'Network errors',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-errors',
|
||||
},
|
||||
{
|
||||
title: 'Network drops',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-drops',
|
||||
},
|
||||
{
|
||||
title: 'Network connections',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#network-connections',
|
||||
},
|
||||
{
|
||||
title: 'System disk io (bytes transferred)',
|
||||
yAxisUnit: 'bytes',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#system-disk-io-bytes',
|
||||
},
|
||||
{
|
||||
title: 'System disk operations/s',
|
||||
yAxisUnit: 'short',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/host-monitoring/#system-disk-operationss',
|
||||
},
|
||||
{
|
||||
title: 'Queue size',
|
||||
yAxisUnit: 'short',
|
||||
docPath: '/infrastructure-monitoring/host-monitoring/#queue-size',
|
||||
},
|
||||
{
|
||||
title: 'System disk operation time/s',
|
||||
yAxisUnit: 's',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/host-monitoring/#system-disk-operation-times',
|
||||
},
|
||||
{
|
||||
title: 'Disk Usage (%) by mountpoint',
|
||||
yAxisUnit: 'percentunit',
|
||||
docPath:
|
||||
'/infrastructure-monitoring/host-monitoring/#disk-usage--by-mountpoint',
|
||||
},
|
||||
{ title: 'CPU Usage', yAxisUnit: 'percentunit' },
|
||||
{ title: 'Memory Usage', yAxisUnit: 'bytes' },
|
||||
{ title: 'System Load Average', yAxisUnit: '' },
|
||||
{ title: 'Network usage (bytes)', yAxisUnit: 'bytes' },
|
||||
{ title: 'Network usage (packet/s)', yAxisUnit: 'pps' },
|
||||
{ title: 'Network errors', yAxisUnit: 'short' },
|
||||
{ title: 'Network drops', yAxisUnit: 'short' },
|
||||
{ title: 'Network connections', yAxisUnit: 'short' },
|
||||
{ title: 'System disk io (bytes transferred)', yAxisUnit: 'bytes' },
|
||||
{ title: 'System disk operations/s', yAxisUnit: 'short' },
|
||||
{ title: 'Queue size', yAxisUnit: 'short' },
|
||||
{ title: 'System disk operation time/s', yAxisUnit: 's' },
|
||||
{ title: 'Disk Usage (%) by mountpoint', yAxisUnit: 'percentunit' },
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -27,7 +28,6 @@ import (
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -110,44 +110,42 @@ func TestAggrGroup(t *testing.T) {
|
||||
}
|
||||
)
|
||||
|
||||
type notification struct {
|
||||
alerts alertmanagertypes.AlertSlice
|
||||
notifiedAt time.Time
|
||||
}
|
||||
alertsCh := make(chan notification)
|
||||
var (
|
||||
last = time.Now()
|
||||
current = time.Now()
|
||||
lastCurMtx = &sync.Mutex{}
|
||||
alertsCh = make(chan alertmanagertypes.AlertSlice)
|
||||
)
|
||||
|
||||
ntfy := func(ctx context.Context, alerts ...*alertmanagertypes.Alert) bool {
|
||||
// Validate that the context is properly populated.
|
||||
notifiedAt, ok := notify.Now(ctx)
|
||||
assert.True(t, ok, "now missing")
|
||||
_, ok = notify.GroupKey(ctx)
|
||||
assert.True(t, ok, "group key missing")
|
||||
lbls, ok := notify.GroupLabels(ctx)
|
||||
if assert.True(t, ok, "group labels missing") {
|
||||
assert.Equal(t, lset, lbls, "wrong group labels")
|
||||
if _, ok := notify.Now(ctx); !ok {
|
||||
t.Errorf("now missing")
|
||||
}
|
||||
rcv, ok := notify.ReceiverName(ctx)
|
||||
if assert.True(t, ok, "receiver missing") {
|
||||
assert.Equal(t, opts.Receiver, rcv, "wrong receiver")
|
||||
if _, ok := notify.GroupKey(ctx); !ok {
|
||||
t.Errorf("group key missing")
|
||||
}
|
||||
ri, ok := notify.RepeatInterval(ctx)
|
||||
if assert.True(t, ok, "repeat interval missing") {
|
||||
assert.Equal(t, notificationConfig.Renotify.RenotifyInterval, ri, "wrong repeat interval")
|
||||
if lbls, ok := notify.GroupLabels(ctx); !ok || !reflect.DeepEqual(lbls, lset) {
|
||||
t.Errorf("wrong group labels: %q", lbls)
|
||||
}
|
||||
if rcv, ok := notify.ReceiverName(ctx); !ok || rcv != opts.Receiver {
|
||||
t.Errorf("wrong receiver: %q", rcv)
|
||||
}
|
||||
if ri, ok := notify.RepeatInterval(ctx); !ok || ri != notificationConfig.Renotify.RenotifyInterval {
|
||||
t.Errorf("wrong repeat interval: %q", ri)
|
||||
}
|
||||
|
||||
alertsCh <- notification{
|
||||
alerts: alertmanagertypes.AlertSlice(alerts),
|
||||
notifiedAt: notifiedAt,
|
||||
}
|
||||
lastCurMtx.Lock()
|
||||
last = current
|
||||
// Subtract a millisecond to allow for races.
|
||||
current = time.Now().Add(-time.Millisecond)
|
||||
lastCurMtx.Unlock()
|
||||
|
||||
alertsCh <- alertmanagertypes.AlertSlice(alerts)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
assertNotifiedAfter := func(previous, current time.Time, interval time.Duration) {
|
||||
t.Helper()
|
||||
require.GreaterOrEqual(t, current.Sub(previous), interval, "received batch too early")
|
||||
}
|
||||
|
||||
removeEndsAt := func(as alertmanagertypes.AlertSlice) alertmanagertypes.AlertSlice {
|
||||
for i, a := range as {
|
||||
ac := *a
|
||||
@@ -158,26 +156,29 @@ func TestAggrGroup(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test regular situation where we wait for group_wait to send out alerts.
|
||||
groupStartedAt := time.Now()
|
||||
ag := newAggrGroup(context.Background(), lset, route, nil, promslog.NewNopLogger(), notificationConfig.Renotify.RenotifyInterval)
|
||||
|
||||
go ag.run(ntfy)
|
||||
|
||||
ag.insert(a1)
|
||||
var lastNotificationAt time.Time
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupWait):
|
||||
require.FailNow(t, "expected initial batch after group_wait")
|
||||
t.Fatalf("expected initial batch after group_wait")
|
||||
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(groupStartedAt, notification.notifiedAt, opts.GroupWait)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupWait {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1})
|
||||
sort.Sort(batch)
|
||||
|
||||
require.Equal(t, exp, batch)
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -186,16 +187,21 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a3})
|
||||
sort.Sort(batch)
|
||||
|
||||
require.Equal(t, exp, batch)
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,15 +220,15 @@ func TestAggrGroup(t *testing.T) {
|
||||
// a2 lies way in the past so the initial group_wait should be skipped.
|
||||
select {
|
||||
case <-time.After(opts.GroupWait / 2):
|
||||
require.FailNow(t, "expected immediate alert but received none")
|
||||
t.Fatalf("expected immediate alert but received none")
|
||||
|
||||
case notification := <-alertsCh:
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
case batch := <-alertsCh:
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2})
|
||||
sort.Sort(batch)
|
||||
|
||||
require.Equal(t, exp, batch)
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -231,16 +237,21 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
exp := removeEndsAt(alertmanagertypes.AlertSlice{a1, a2, a3})
|
||||
sort.Sort(batch)
|
||||
|
||||
require.Equal(t, exp, batch)
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,14 +263,19 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
lastNotificationAt = notification.notifiedAt
|
||||
batch := notification.alerts
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
sort.Sort(batch)
|
||||
|
||||
require.Equal(t, exp, batch)
|
||||
if !reflect.DeepEqual(batch, exp) {
|
||||
t.Fatalf("expected alerts %v but got %v", exp, batch)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve all remaining alerts, they should be removed after the next batch was sent.
|
||||
@@ -273,16 +289,24 @@ func TestAggrGroup(t *testing.T) {
|
||||
|
||||
select {
|
||||
case <-time.After(2 * opts.GroupInterval):
|
||||
require.FailNow(t, "expected new batch after group interval but received none")
|
||||
t.Fatalf("expected new batch after group interval but received none")
|
||||
|
||||
case notification := <-alertsCh:
|
||||
assertNotifiedAfter(lastNotificationAt, notification.notifiedAt, opts.GroupInterval)
|
||||
batch := notification.alerts
|
||||
case batch := <-alertsCh:
|
||||
lastCurMtx.Lock()
|
||||
s := time.Since(last)
|
||||
lastCurMtx.Unlock()
|
||||
if s < opts.GroupInterval {
|
||||
t.Fatalf("received batch too early after %v", s)
|
||||
}
|
||||
sort.Sort(batch)
|
||||
|
||||
require.Equal(t, resolved, batch)
|
||||
if !reflect.DeepEqual(batch, resolved) {
|
||||
t.Fatalf("expected alerts %v but got %v", resolved, batch)
|
||||
}
|
||||
|
||||
require.Eventually(t, ag.empty, 2*opts.GroupInterval, 10*time.Millisecond, "expected aggregation group to be empty after resolving alerts: %v", ag)
|
||||
if !ag.empty() {
|
||||
t.Fatalf("Expected aggregation group to be empty after resolving alerts: %v", ag)
|
||||
}
|
||||
}
|
||||
|
||||
ag.stop()
|
||||
@@ -316,7 +340,9 @@ func TestGroupLabels(t *testing.T) {
|
||||
|
||||
ls := getGroupLabels(a, route.RouteOpts.GroupBy, false)
|
||||
|
||||
require.Equal(t, expLs, ls)
|
||||
if !reflect.DeepEqual(ls, expLs) {
|
||||
t.Fatalf("expected labels are %v, but got %v", expLs, ls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggrRouteMap(t *testing.T) {
|
||||
@@ -332,13 +358,17 @@ route:
|
||||
group_interval: 1m
|
||||
receiver: 'slack'`
|
||||
conf, err := config.Load(confData)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer alerts.Close()
|
||||
|
||||
timeout := func(d time.Duration) time.Duration { return time.Duration(0) }
|
||||
@@ -347,7 +377,9 @@ route:
|
||||
store := nfroutingstoretest.NewMockSQLRouteStore()
|
||||
store.MatchExpectationsInOrder(false)
|
||||
nfManager, err := rulebasednotification.New(context.Background(), providerSettings, nfmanager.Config{}, store)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orgId := "test-org"
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -465,7 +497,9 @@ route:
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = alerts.Put(ctx, inputAlerts...)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Let alerts get processed.
|
||||
for i := 0; len(recorder.Alerts()) != 4; i++ {
|
||||
@@ -597,13 +631,17 @@ route:
|
||||
group_interval: 10ms
|
||||
receiver: 'slack'`
|
||||
conf, err := config.Load(confData)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer alerts.Close()
|
||||
|
||||
timeout := func(d time.Duration) time.Duration { return time.Duration(0) }
|
||||
@@ -612,7 +650,9 @@ route:
|
||||
store := nfroutingstoretest.NewMockSQLRouteStore()
|
||||
store.MatchExpectationsInOrder(false)
|
||||
nfManager, err := rulebasednotification.New(context.Background(), providerSettings, nfmanager.Config{}, store)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orgId := "test-org"
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -759,7 +799,9 @@ route:
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = alerts.Put(ctx, inputAlerts...)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; len(recorder.Alerts()) != 9; i++ {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
@@ -848,13 +890,17 @@ route:
|
||||
group_interval: 10ms
|
||||
receiver: 'slack'`
|
||||
conf, err := config.Load(confData)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer alerts.Close()
|
||||
|
||||
timeout := func(d time.Duration) time.Duration { return time.Duration(0) }
|
||||
@@ -863,7 +909,9 @@ route:
|
||||
store := nfroutingstoretest.NewMockSQLRouteStore()
|
||||
store.MatchExpectationsInOrder(false)
|
||||
nfManager, err := rulebasednotification.New(context.Background(), providerSettings, nfmanager.Config{}, store)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orgId := "test-org"
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -981,7 +1029,9 @@ route:
|
||||
require.NoError(t, err)
|
||||
}
|
||||
err = alerts.Put(ctx, inputAlerts...)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; len(recorder.Alerts()) != 3 && i < 15; i++ {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
@@ -1110,7 +1160,9 @@ func TestDispatcherRace(t *testing.T) {
|
||||
logger := promslog.NewNopLogger()
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer alerts.Close()
|
||||
|
||||
timeout := func(d time.Duration) time.Duration { return time.Duration(0) }
|
||||
@@ -1136,13 +1188,17 @@ route:
|
||||
group_interval: 5m
|
||||
receiver: 'slack'`
|
||||
conf, err := config.Load(confData)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer alerts.Close()
|
||||
timeout := func(d time.Duration) time.Duration { return d }
|
||||
recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*alertmanagertypes.Alert)}
|
||||
@@ -1150,7 +1206,9 @@ route:
|
||||
store := nfroutingstoretest.NewMockSQLRouteStore()
|
||||
store.MatchExpectationsInOrder(false)
|
||||
nfManager, err := rulebasednotification.New(context.Background(), providerSettings, nfmanager.Config{}, store)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orgId := "test-org"
|
||||
|
||||
for i := 0; i < numAlerts; i++ {
|
||||
@@ -1209,7 +1267,9 @@ func TestDispatcher_DoMaintenance(t *testing.T) {
|
||||
marker := alertmanagertypes.NewMarker(r)
|
||||
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Minute, 0, nil, promslog.NewNopLogger(), prometheus.NewRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
route := &dispatch.Route{
|
||||
RouteOpts: dispatch.RouteOpts{
|
||||
@@ -1303,14 +1363,18 @@ route:
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
conf, err := config.Load(tc.confData)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer alerts.Close()
|
||||
|
||||
timeout := func(d time.Duration) time.Duration { return time.Duration(0) }
|
||||
@@ -1319,7 +1383,9 @@ route:
|
||||
store := nfroutingstoretest.NewMockSQLRouteStore()
|
||||
store.MatchExpectationsInOrder(false)
|
||||
nfManager, err := rulebasednotification.New(context.Background(), providerSettings, nfmanager.Config{}, store)
|
||||
require.NoError(t, err)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := NewDispatcher(alerts, route, recorder, marker, timeout, nil, logger, metrics, nfManager, "test-org")
|
||||
// setup the dispatcher for tests
|
||||
d.receiverRoutes = map[string]*dispatch.Route{}
|
||||
|
||||
@@ -15,6 +15,8 @@ var (
|
||||
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
|
||||
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
|
||||
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
|
||||
|
||||
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -115,6 +117,14 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUsePrometheusClickhouseV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Runs PromQL queries on the clickhousev2 provider alongside the served engine result and logs any difference; serving is unaffected.",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -33,13 +33,7 @@ func (handler *handler) GetFieldsKeys(rw http.ResponseWriter, req *http.Request)
|
||||
|
||||
fieldKeySelector := telemetrytypes.NewFieldKeySelectorFromPostableFieldKeysParams(params)
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
keys, complete, err := handler.telemetryMetadataStore.GetKeys(ctx, valuer.MustNewUUID(claims.OrgID), fieldKeySelector)
|
||||
keys, complete, err := handler.telemetryMetadataStore.GetKeys(ctx, fieldKeySelector)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -74,7 +68,7 @@ func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
relatedValues, relatedComplete, err := handler.telemetryMetadataStore.GetRelatedValues(ctx, valuer.MustNewUUID(claims.OrgID), fieldValueSelector)
|
||||
relatedValues, relatedComplete, err := handler.telemetryMetadataStore.GetRelatedValues(ctx, fieldValueSelector)
|
||||
if err != nil {
|
||||
// we don't want to return error if we fail to get related values for some reason
|
||||
relatedValues = []string{}
|
||||
|
||||
@@ -208,7 +208,6 @@ func (m *module) getContainersTableMetadata(ctx context.Context, orgID valuer.UU
|
||||
// runs the query. The returned counts map is empty (never nil) when gated off.
|
||||
func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -238,7 +237,7 @@ func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
|
||||
return map[string]containerStatusCounts{}, warning, nil
|
||||
}
|
||||
|
||||
counts, err := m.getPerGroupContainerStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups)
|
||||
counts, err := m.getPerGroupContainerStatusCounts(ctx, start, end, filter, groupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -264,7 +263,6 @@ func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
func (m *module) getPerGroupContainerStatusCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -290,7 +288,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
err error
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -529,7 +527,6 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
// Groups absent from the result map have no data (caller default).
|
||||
func (m *module) getPerGroupContainerRestartCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -553,7 +550,7 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
err error
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -673,7 +670,6 @@ func (m *module) getPerGroupContainerRestartCounts(
|
||||
// Groups absent from the result map have no data (caller default).
|
||||
func (m *module) getPerGroupContainerReadyCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -697,7 +693,7 @@ func (m *module) getPerGroupContainerReadyCounts(
|
||||
err error
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ func (m *module) buildReducedSamplesTblFingerprintSubQuery(metricNames []string,
|
||||
return fpSB
|
||||
}
|
||||
|
||||
func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := ""
|
||||
if filter != nil {
|
||||
expression = strings.TrimSpace(filter.Expression)
|
||||
@@ -413,7 +413,7 @@ func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filte
|
||||
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||
}
|
||||
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, orgID, whereClauseSelectors)
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, whereClauseSelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -646,7 +646,7 @@ func (m *module) getMetadata(
|
||||
var filterClause *sqlbuilder.WhereClause
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
var err error
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, filter, startMs, endMs)
|
||||
filterClause, err = m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -692,7 +692,7 @@ func (m *module) getMetadata(
|
||||
)
|
||||
|
||||
if filter != nil && strings.TrimSpace(filter.Expression) != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, orgID, filter, startMs, endMs)
|
||||
filterClause, err := m.buildFilterClause(ctx, filter, startMs, endMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -860,7 +860,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
var filterClause *sqlbuilder.WhereClause
|
||||
if mergedFilterExpr != "" {
|
||||
var err error
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -904,7 +904,7 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
fmt.Sprintf("fingerprint IN (%s)", sb.Var(fpSB)),
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
var filterClause *sqlbuilder.WhereClause
|
||||
if filterExpr != "" {
|
||||
var err error
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (m *module) getPerGroupHostStatusCounts(
|
||||
)
|
||||
|
||||
if filterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -332,17 +332,17 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -436,17 +436,17 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -540,17 +540,17 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podPhaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podPhaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -644,12 +644,12 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
@@ -750,17 +750,17 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podPhaseCountsMap, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podPhaseCountsMap, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
@@ -941,12 +941,12 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -1046,12 +1046,12 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -1151,12 +1151,12 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
@@ -1256,12 +1256,12 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
phaseCounts, err = m.getPerGroupPodPhaseCounts(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
|
||||
|
||||
@@ -177,7 +177,6 @@ func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, r
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
func (m *module) getPerGroupNodeConditionCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -218,7 +217,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
timeSeriesFPs.LE("unix_milli", flooredEndMs),
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -248,7 +248,6 @@ func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, re
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
func (m *module) getPerGroupPodPhaseCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -289,7 +288,7 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
timeSeriesFPs.LE("unix_milli", flooredEndMs),
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err := m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -398,7 +397,6 @@ func (m *module) getPerGroupPodPhaseCounts(
|
||||
// runs the query. The returned counts map is empty (never nil) when gated off.
|
||||
func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -428,7 +426,7 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
|
||||
return map[string]podStatusCounts{}, warning, nil
|
||||
}
|
||||
|
||||
counts, err := m.getPerGroupPodStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups)
|
||||
counts, err := m.getPerGroupPodStatusCounts(ctx, start, end, filter, groupBy, pageGroups)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -450,7 +448,6 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
func (m *module) getPerGroupPodStatusCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -479,7 +476,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
err error
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -777,7 +774,6 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
// Groups absent from the result map have no data (caller default).
|
||||
func (m *module) getPerGroupPodRestartCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end int64,
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
@@ -802,7 +798,7 @@ func (m *module) getPerGroupPodRestartCounts(
|
||||
err error
|
||||
)
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
filterClause, err = m.buildFilterClause(ctx, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -565,7 +565,7 @@ func (m *module) InspectMetrics(
|
||||
start := uint64(req.Start)
|
||||
end := uint64(req.End)
|
||||
|
||||
filterWhereClause, err := m.buildFilterClause(ctx, orgID, req.Filter, req.Start, req.End)
|
||||
filterWhereClause, err := m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -950,7 +950,7 @@ func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, r
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := ""
|
||||
if filter != nil {
|
||||
expression = strings.TrimSpace(filter.Expression)
|
||||
@@ -969,7 +969,7 @@ func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filte
|
||||
// whereClauseSelectors[idx].Source = query.Source
|
||||
}
|
||||
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, orgID, whereClauseSelectors)
|
||||
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, whereClauseSelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1010,7 +1010,7 @@ func (m *module) fetchMetricsStatsWithSamples(
|
||||
var filterWhereClause *sqlbuilder.WhereClause
|
||||
if hasFilter {
|
||||
var err error
|
||||
filterWhereClause, err = m.buildFilterClause(ctx, orgID, req.Filter, req.Start, req.End)
|
||||
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -1214,7 +1214,7 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, orgID valuer.UUID
|
||||
var filterWhereClause *sqlbuilder.WhereClause
|
||||
if hasFilter {
|
||||
var err error
|
||||
filterWhereClause, err = m.buildFilterClause(ctx, orgID, req.Filter, req.Start, req.End)
|
||||
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1332,7 +1332,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, orgID valuer.UUID, r
|
||||
var filterWhereClause *sqlbuilder.WhereClause
|
||||
if hasFilter {
|
||||
var err error
|
||||
filterWhereClause, err = m.buildFilterClause(ctx, orgID, req.Filter, req.Start, req.End)
|
||||
filterWhereClause, err = m.buildFilterClause(ctx, req.Filter, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
@@ -23,7 +22,6 @@ func newConditionBuilder(fm qbtypes.FieldMapper) qbtypes.ConditionBuilder {
|
||||
|
||||
func (c *conditionBuilder) ConditionFor(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
@@ -49,7 +47,7 @@ func (c *conditionBuilder) ConditionFor(
|
||||
|
||||
conds := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
cond, err := c.conditionForKey(ctx, startNs, endNs, k, operator, value, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -60,7 +58,6 @@ func (c *conditionBuilder) ConditionFor(
|
||||
|
||||
func (c *conditionBuilder) conditionForKey(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
@@ -72,7 +69,7 @@ func (c *conditionBuilder) conditionForKey(
|
||||
value = querybuilder.FormatValueForContains(value)
|
||||
}
|
||||
|
||||
fieldName, err := c.fm.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
fieldName, err := c.fm.FieldFor(ctx, startNs, endNs, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
@@ -31,12 +30,6 @@ func newFieldMapper() qbtypes.FieldMapper {
|
||||
return &fieldMapper{}
|
||||
}
|
||||
|
||||
// CandidateKeys returns nil: rule-state history has no attribute-map fallback, so a
|
||||
// context-missing key stays unresolved and the caller errors.
|
||||
func (m *fieldMapper) CandidateKeys(_ context.Context, _ valuer.UUID, _ *telemetrytypes.TelemetryFieldKey, _ any, _ map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.TelemetryFieldKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) { //nolint:unparam
|
||||
name := strings.TrimSpace(key.Name)
|
||||
if col, ok := ruleStateHistoryColumns[name]; ok {
|
||||
@@ -45,7 +38,7 @@ func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.Telemetry
|
||||
return ruleStateHistoryColumns["labels"], nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
func (m *fieldMapper) FieldFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
col, err := m.getColumn(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -56,7 +49,7 @@ func (m *fieldMapper) FieldFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
|
||||
return col.Name, nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
|
||||
func (m *fieldMapper) ColumnFor(ctx context.Context, _, _ uint64, key *telemetrytypes.TelemetryFieldKey) ([]*schema.Column, error) {
|
||||
col, err := m.getColumn(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -64,8 +57,8 @@ func (m *fieldMapper) ColumnFor(ctx context.Context, _ valuer.UUID, _, _ uint64,
|
||||
return []*schema.Column{col}, nil
|
||||
}
|
||||
|
||||
func (m *fieldMapper) ColumnExpressionFor(ctx context.Context, orgID valuer.UUID, tsStart, tsEnd uint64, field *telemetrytypes.TelemetryFieldKey, _ map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
colName, err := m.FieldFor(ctx, orgID, tsStart, tsEnd, field)
|
||||
func (m *fieldMapper) ColumnExpressionFor(ctx context.Context, tsStart, tsEnd uint64, field *telemetrytypes.TelemetryFieldKey, _ map[string][]*telemetrytypes.TelemetryFieldKey) (string, error) {
|
||||
colName, err := m.FieldFor(ctx, tsStart, tsEnd, field)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -10,11 +10,9 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -87,13 +85,7 @@ func (h *handler) GetRuleHistoryTimeline(w http.ResponseWriter, r *http.Request)
|
||||
req.Query.Limit = 50
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
timelineItems, timelineTotal, err := h.module.GetHistoryTimeline(r.Context(), valuer.MustNewUUID(claims.OrgID), ruleID, req.Query)
|
||||
timelineItems, timelineTotal, err := h.module.GetHistoryTimeline(r.Context(), ruleID, req.Query)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -135,13 +127,7 @@ func (h *handler) GetRuleHistoryContributors(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.module.GetHistoryContributors(r.Context(), valuer.MustNewUUID(claims.OrgID), ruleID, query)
|
||||
res, err := h.module.GetHistoryContributors(r.Context(), ruleID, query)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -166,13 +152,7 @@ func (h *handler) GetRuleHistoryFilterKeys(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.module.GetHistoryFilterKeys(r.Context(), valuer.MustNewUUID(claims.OrgID), ruleID, query, search, limit)
|
||||
res, err := h.module.GetHistoryFilterKeys(r.Context(), ruleID, query, search, limit)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -187,13 +167,7 @@ func (h *handler) GetRuleHistoryFilterValues(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.module.GetHistoryFilterValues(r.Context(), valuer.MustNewUUID(claims.OrgID), ruleID, key, query, search, limit)
|
||||
res, err := h.module.GetHistoryFilterValues(r.Context(), ruleID, key, query, search, limit)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
@@ -24,20 +23,20 @@ func (m *module) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string
|
||||
return m.store.GetLastSavedRuleStateHistory(ctx, ruleID)
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error) {
|
||||
return m.store.ReadRuleStateHistoryByRuleID(ctx, orgID, ruleID, &query)
|
||||
func (m *module) GetHistoryTimeline(ctx context.Context, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error) {
|
||||
return m.store.ReadRuleStateHistoryByRuleID(ctx, ruleID, &query)
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryFilterKeys(ctx context.Context, orgID valuer.UUID, ruleID string, query rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldKeys, error) {
|
||||
return m.store.ReadRuleStateHistoryFilterKeysByRuleID(ctx, orgID, ruleID, &query, search, limit)
|
||||
func (m *module) GetHistoryFilterKeys(ctx context.Context, ruleID string, query rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldKeys, error) {
|
||||
return m.store.ReadRuleStateHistoryFilterKeysByRuleID(ctx, ruleID, &query, search, limit)
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryFilterValues(ctx context.Context, orgID valuer.UUID, ruleID string, key string, query rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldValues, error) {
|
||||
return m.store.ReadRuleStateHistoryFilterValuesByRuleID(ctx, orgID, ruleID, key, &query, search, limit)
|
||||
func (m *module) GetHistoryFilterValues(ctx context.Context, ruleID string, key string, query rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldValues, error) {
|
||||
return m.store.ReadRuleStateHistoryFilterValuesByRuleID(ctx, ruleID, key, &query, search, limit)
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error) {
|
||||
return m.store.ReadRuleStateHistoryTopContributorsByRuleID(ctx, orgID, ruleID, &query)
|
||||
func (m *module) GetHistoryContributors(ctx context.Context, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error) {
|
||||
return m.store.ReadRuleStateHistoryTopContributorsByRuleID(ctx, ruleID, &query)
|
||||
}
|
||||
|
||||
func (m *module) GetHistoryOverallStatus(ctx context.Context, ruleID string, query rulestatehistorytypes.Query) ([]rulestatehistorytypes.GettableRuleStateWindow, error) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
@@ -103,7 +102,7 @@ func (s *store) GetLastSavedRuleStateHistory(ctx context.Context, ruleID string)
|
||||
return history, nil
|
||||
}
|
||||
|
||||
func (s *store) ReadRuleStateHistoryByRuleID(ctx context.Context, orgID valuer.UUID, ruleID string, query *rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error) {
|
||||
func (s *store) ReadRuleStateHistoryByRuleID(ctx context.Context, ruleID string, query *rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"rule_id",
|
||||
@@ -120,7 +119,7 @@ func (s *store) ReadRuleStateHistoryByRuleID(ctx context.Context, orgID valuer.U
|
||||
sb.From(historyTable())
|
||||
s.applyBaseHistoryFilters(sb, ruleID, query)
|
||||
|
||||
whereClause, err := s.buildFilterClause(ctx, orgID, query.FilterExpression, query.Start, query.End)
|
||||
whereClause, err := s.buildFilterClause(ctx, query.FilterExpression, query.Start, query.End)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -156,7 +155,7 @@ func (s *store) ReadRuleStateHistoryByRuleID(ctx context.Context, orgID valuer.U
|
||||
return history, total, nil
|
||||
}
|
||||
|
||||
func (s *store) ReadRuleStateHistoryFilterKeysByRuleID(ctx context.Context, orgID valuer.UUID, ruleID string, query *rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldKeys, error) {
|
||||
func (s *store) ReadRuleStateHistoryFilterKeysByRuleID(ctx context.Context, ruleID string, query *rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldKeys, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -173,7 +172,7 @@ func (s *store) ReadRuleStateHistoryFilterKeysByRuleID(ctx context.Context, orgI
|
||||
sb.Where(fmt.Sprintf("positionCaseInsensitiveUTF8(%s, %s) > 0", keyExpr, sb.Var(search)))
|
||||
}
|
||||
|
||||
whereClause, err := s.buildFilterClause(ctx, orgID, query.FilterExpression, query.Start, query.End)
|
||||
whereClause, err := s.buildFilterClause(ctx, query.FilterExpression, query.Start, query.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -227,7 +226,7 @@ func (s *store) ReadRuleStateHistoryFilterKeysByRuleID(ctx context.Context, orgI
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *store) ReadRuleStateHistoryFilterValuesByRuleID(ctx context.Context, orgID valuer.UUID, ruleID string, key string, query *rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldValues, error) {
|
||||
func (s *store) ReadRuleStateHistoryFilterValuesByRuleID(ctx context.Context, ruleID string, key string, query *rulestatehistorytypes.Query, search string, limit int64) (*telemetrytypes.GettableFieldValues, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -245,7 +244,7 @@ func (s *store) ReadRuleStateHistoryFilterValuesByRuleID(ctx context.Context, or
|
||||
sb.Where(fmt.Sprintf("positionCaseInsensitiveUTF8(%s, %s) > 0", valExpr, sb.Var(search)))
|
||||
}
|
||||
|
||||
whereClause, err := s.buildFilterClause(ctx, orgID, query.FilterExpression, query.Start, query.End)
|
||||
whereClause, err := s.buildFilterClause(ctx, query.FilterExpression, query.Start, query.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -292,7 +291,7 @@ func (s *store) ReadRuleStateHistoryFilterValuesByRuleID(ctx context.Context, or
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *store) ReadRuleStateHistoryTopContributorsByRuleID(ctx context.Context, orgID valuer.UUID, ruleID string, query *rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error) {
|
||||
func (s *store) ReadRuleStateHistoryTopContributorsByRuleID(ctx context.Context, ruleID string, query *rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"fingerprint",
|
||||
@@ -306,7 +305,7 @@ func (s *store) ReadRuleStateHistoryTopContributorsByRuleID(ctx context.Context,
|
||||
sb.Where(sb.GE("unix_milli", query.Start))
|
||||
sb.Where(sb.LT("unix_milli", query.End))
|
||||
|
||||
whereClause, err := s.buildFilterClause(ctx, orgID, query.FilterExpression, query.Start, query.End)
|
||||
whereClause, err := s.buildFilterClause(ctx, query.FilterExpression, query.Start, query.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -473,7 +472,7 @@ func (s *store) querySeries(ctx context.Context, selectQuery string, args ...any
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func (s *store) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
func (s *store) buildFilterClause(ctx context.Context, filter qbtypes.Filter, startMillis, endMillis int64) (*sqlbuilder.WhereClause, error) {
|
||||
expression := strings.TrimSpace(filter.Expression)
|
||||
if expression == "" {
|
||||
return nil, nil //nolint:nilnil
|
||||
@@ -484,7 +483,7 @@ func (s *store) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter
|
||||
selectors[i].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
|
||||
}
|
||||
|
||||
fieldKeys, _, err := s.telemetryMetadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
fieldKeys, _, err := s.telemetryMetadataStore.GetKeysMulti(ctx, selectors)
|
||||
if err != nil || len(fieldKeys) == 0 {
|
||||
fieldKeys = map[string][]*telemetrytypes.TelemetryFieldKey{}
|
||||
for _, sel := range selectors {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Module defines the core operations for managing rule state history.
|
||||
@@ -24,16 +23,16 @@ type Module interface {
|
||||
|
||||
// GetHistoryTimeline returns a time-ordered list of rule state history entries and a total count
|
||||
// for the given query, suitable for paginated timeline views.
|
||||
GetHistoryTimeline(context.Context, valuer.UUID, string, rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error)
|
||||
GetHistoryTimeline(context.Context, string, rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistory, uint64, error)
|
||||
|
||||
// GetHistoryFilterKeys returns the available filter keys for rule state history queries.
|
||||
GetHistoryFilterKeys(context.Context, valuer.UUID, string, rulestatehistorytypes.Query, string, int64) (*telemetrytypes.GettableFieldKeys, error)
|
||||
GetHistoryFilterKeys(context.Context, string, rulestatehistorytypes.Query, string, int64) (*telemetrytypes.GettableFieldKeys, error)
|
||||
|
||||
// GetHistoryFilterValues returns the available values for a specific filter key in rule state history.
|
||||
GetHistoryFilterValues(context.Context, valuer.UUID, string, string, rulestatehistorytypes.Query, string, int64) (*telemetrytypes.GettableFieldValues, error)
|
||||
GetHistoryFilterValues(context.Context, string, string, rulestatehistorytypes.Query, string, int64) (*telemetrytypes.GettableFieldValues, error)
|
||||
|
||||
// GetHistoryContributors returns the top contributors to trigger alert, for the given query.
|
||||
GetHistoryContributors(context.Context, valuer.UUID, string, rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error)
|
||||
GetHistoryContributors(context.Context, string, rulestatehistorytypes.Query) ([]rulestatehistorytypes.RuleStateHistoryContributor, error)
|
||||
|
||||
// GetHistoryOverallStatus returns the overall status windows for rule state history,
|
||||
// providing an aggregated view of rule health over time.
|
||||
|
||||
89
pkg/prometheus/clickhouseprometheusv2/capture.go
Normal file
89
pkg/prometheus/clickhouseprometheusv2/capture.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// statementRecorder collects the statements a PromQL evaluation would run.
|
||||
// Safe for concurrent use: the engine may Select selectors concurrently.
|
||||
type statementRecorder struct {
|
||||
mu sync.Mutex
|
||||
statements []prometheus.CapturedStatement
|
||||
}
|
||||
|
||||
func (r *statementRecorder) record(query string, args []any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
|
||||
}
|
||||
|
||||
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]prometheus.CapturedStatement, len(r.statements))
|
||||
copy(out, r.statements)
|
||||
return out
|
||||
}
|
||||
|
||||
type captureQueryable struct {
|
||||
client *client
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &captureQuerier{
|
||||
querier: querier{mint: mint, maxt: maxt, client: c.client},
|
||||
recorder: c.recorder,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// captureQuerier builds the same SQL as the live querier but records it and
|
||||
// returns no data. The fingerprint filter always takes the subquery form:
|
||||
// without executing the series lookup, the inline literal set is unknown.
|
||||
type captureQuerier struct {
|
||||
querier
|
||||
recorder *statementRecorder
|
||||
}
|
||||
|
||||
func (c *captureQuerier) Select(ctx context.Context, _ bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
if rawQuery, ok := rawSQLQuery(matchers); ok {
|
||||
c.recorder.record(rawQuery, nil)
|
||||
return storage.EmptySeriesSet()
|
||||
}
|
||||
|
||||
start, end := c.window(hints)
|
||||
|
||||
samplesQuery, args, err := buildSamplesQuery(start, end, metricNamesFromMatchers(matchers), nil, matchers, c.lastSamplePerStepFor(ctx, hints))
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
c.recorder.record(samplesQuery, args)
|
||||
|
||||
return storage.EmptySeriesSet()
|
||||
}
|
||||
|
||||
func (c *captureQuerier) LabelValues(context.Context, string, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (c *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// metricNamesFromMatchers extracts the statically known metric name, if any.
|
||||
// The live path derives names from the matched series; the capture path has
|
||||
// no execution results, so only a __name__ equality contributes.
|
||||
func metricNamesFromMatchers(matchers []*labels.Matcher) []string {
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && m.Value != "" {
|
||||
return []string{m.Value}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
282
pkg/prometheus/clickhouseprometheusv2/client.go
Normal file
282
pkg/prometheus/clickhouseprometheusv2/client.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
)
|
||||
|
||||
// seriesLookup is a series-lookup result: matched fingerprints with their
|
||||
// labels, and the distinct metric names seen on them.
|
||||
type seriesLookup struct {
|
||||
fingerprints map[uint64]labels.Labels
|
||||
metricNames []string
|
||||
}
|
||||
|
||||
// client executes the series, samples and raw queries against ClickHouse.
|
||||
type client struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
cfg prometheus.ClickhouseV2Config
|
||||
lookbackMs int64
|
||||
}
|
||||
|
||||
func newClient(settings factory.ScopedProviderSettings, telemetryStore telemetrystore.TelemetryStore, cfg prometheus.Config) *client {
|
||||
lookback := cfg.LookbackDelta
|
||||
if lookback <= 0 {
|
||||
// Mirror the engine: promql defaults an unset lookback to 5m.
|
||||
lookback = defaultLookbackDelta
|
||||
}
|
||||
return &client{
|
||||
settings: settings,
|
||||
telemetryStore: telemetryStore,
|
||||
cfg: cfg.ClickhouseV2,
|
||||
lookbackMs: lookback.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) withContext(ctx context.Context, functionName string) context.Context {
|
||||
return ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-prometheus-v2",
|
||||
instrumentationtypes.CodeFunctionName: functionName,
|
||||
})
|
||||
}
|
||||
|
||||
// selectSeries runs the series lookup for the given matchers and window.
|
||||
func (c *client) selectSeries(ctx context.Context, query string, args []any) (*seriesLookup, error) {
|
||||
ctx = c.withContext(ctx, "selectSeries")
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
lookup := &seriesLookup{fingerprints: make(map[uint64]labels.Labels)}
|
||||
names := make(map[string]struct{})
|
||||
|
||||
var fingerprint uint64
|
||||
var labelsJSON string
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&fingerprint, &labelsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lset, err := unmarshalLabels(labelsJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup.fingerprints[fingerprint] = lset
|
||||
if name := lset.Get(metricNameLabel); name != "" {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
if c.cfg.MaxFetchedSeries > 0 && len(lookup.fingerprints) > c.cfg.MaxFetchedSeries {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"promql selector matched more than %d series; narrow the label matchers or raise prometheus::clickhousev2::max_fetched_series",
|
||||
c.cfg.MaxFetchedSeries,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for name := range names {
|
||||
lookup.metricNames = append(lookup.metricNames, name)
|
||||
}
|
||||
slices.Sort(lookup.metricNames)
|
||||
|
||||
return lookup, nil
|
||||
}
|
||||
|
||||
// unmarshalLabels parses the labels JSON column. Unlike v1, the fingerprint
|
||||
// is not injected as a synthetic label (it would take part in `without (...)`
|
||||
// grouping and vector matching) and empty-valued labels are dropped: an empty
|
||||
// label value means "label absent" in Prometheus, and upstream never produces
|
||||
// such labels, but stored attribute JSON can carry them.
|
||||
func unmarshalLabels(s string) (labels.Labels, error) {
|
||||
m := make(map[string]string)
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
return labels.EmptyLabels(), err
|
||||
}
|
||||
builder := labels.NewScratchBuilder(len(m))
|
||||
for k, v := range m {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
builder.Add(k, v)
|
||||
}
|
||||
builder.Sort()
|
||||
return builder.Labels(), nil
|
||||
}
|
||||
|
||||
// selectSamples executes a samples query (raw or last-sample-per-step; both
|
||||
// produce the same column shape) and assembles the per-series sample slices.
|
||||
// Rows arrive ordered by (fingerprint, unix_milli). Rows whose fingerprint
|
||||
// is missing from the lookup are skipped (possible in the subquery filter
|
||||
// mode, where the fingerprint filter re-runs after the lookup and can see
|
||||
// series born in between). Stale flags map to the engine's StaleNaN.
|
||||
// Duplicate timestamps pass through as stored: upstream Prometheus cannot
|
||||
// produce them (its TSDB rejects them at ingest), our ingest can under
|
||||
// at-least-once retries, and v1 feeds them to the engine as-is —
|
||||
// deduplicating here would make this provider silently disagree with both
|
||||
// v1 and the transpiled statements over the same dirty data. Uniqueness
|
||||
// belongs to the ingest layer.
|
||||
func (c *client) selectSamples(ctx context.Context, query string, args []any, lookup *seriesLookup) ([]*series, error) {
|
||||
ctx = c.withContext(ctx, "selectSamples")
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var (
|
||||
result []*series
|
||||
current *series
|
||||
fingerprint uint64
|
||||
prevFp uint64
|
||||
timestampMs int64
|
||||
val float64
|
||||
flags uint32
|
||||
first = true
|
||||
haveCurrent bool
|
||||
staleMarker = math.Float64frombits(promValue.StaleNaN)
|
||||
maxSamples = c.cfg.MaxFetchedSamples
|
||||
fetched int64
|
||||
unknownCount int
|
||||
)
|
||||
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&fingerprint, ×tampMs, &val, &flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetched++
|
||||
if maxSamples > 0 && fetched > maxSamples {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"promql query would fetch more than %d samples; narrow the selector or time range, or raise prometheus::clickhousev2::max_fetched_samples",
|
||||
maxSamples,
|
||||
)
|
||||
}
|
||||
|
||||
if first || fingerprint != prevFp {
|
||||
first = false
|
||||
prevFp = fingerprint
|
||||
lset, ok := lookup.fingerprints[fingerprint]
|
||||
if !ok {
|
||||
unknownCount++
|
||||
haveCurrent = false
|
||||
continue
|
||||
}
|
||||
current = &series{lset: lset}
|
||||
result = append(result, current)
|
||||
haveCurrent = true
|
||||
}
|
||||
if !haveCurrent {
|
||||
// Remaining rows of a fingerprint missing from the lookup.
|
||||
continue
|
||||
}
|
||||
|
||||
if flags&1 == 1 {
|
||||
val = staleMarker
|
||||
}
|
||||
current.ts = append(current.ts, timestampMs)
|
||||
current.vs = append(current.vs, val)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if unknownCount > 0 {
|
||||
c.settings.Logger().DebugContext(ctx, "skipped samples of fingerprints missing from series lookup",
|
||||
slog.Int("unknown_fingerprints", unknownCount))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// queryRaw supports the {job="rawsql", query="..."} escape hatch: the value of
|
||||
// the query matcher runs as-is, each row becoming a single-sample series
|
||||
// stamped at the query end. Column "value" is the sample value; every other
|
||||
// column is a label.
|
||||
func (c *client) queryRaw(ctx context.Context, query string, ts int64) ([]*series, error) {
|
||||
ctx = c.withContext(ctx, "queryRaw")
|
||||
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columns := rows.Columns()
|
||||
targets := make([]any, len(columns))
|
||||
for i := range targets {
|
||||
targets[i] = new(scanner)
|
||||
}
|
||||
|
||||
var result []*series
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(targets...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builder := labels.NewScratchBuilder(len(columns))
|
||||
var val float64
|
||||
for i, col := range columns {
|
||||
v := targets[i].(*scanner)
|
||||
if col == "value" {
|
||||
val = v.f
|
||||
continue
|
||||
}
|
||||
builder.Add(col, v.s)
|
||||
}
|
||||
builder.Sort()
|
||||
result = append(result, &series{
|
||||
lset: builder.Labels(),
|
||||
ts: []int64{ts},
|
||||
vs: []float64{val},
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var _ sql.Scanner = (*scanner)(nil)
|
||||
|
||||
type scanner struct {
|
||||
f float64
|
||||
s string
|
||||
}
|
||||
|
||||
func (s *scanner) Scan(val any) error {
|
||||
s.f = 0
|
||||
s.s = ""
|
||||
|
||||
s.s = fmt.Sprintf("%v", val)
|
||||
switch val := val.(type) {
|
||||
case int64:
|
||||
s.f = float64(val)
|
||||
case uint64:
|
||||
s.f = float64(val)
|
||||
case float64:
|
||||
s.f = val
|
||||
case []byte:
|
||||
s.s = string(val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
334
pkg/prometheus/clickhouseprometheusv2/doc.go
Normal file
334
pkg/prometheus/clickhouseprometheusv2/doc.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Package clickhouseprometheusv2 is the second-generation ClickHouse-backed
|
||||
// Prometheus provider. It exists because the v1 provider fetches every raw
|
||||
// sample of a query's union window through the remote-read protobuf layer
|
||||
// and hands it to the engine — the cost is a function of ingested data, not
|
||||
// of the question asked, which is how a dashboard of PromQL panels takes an
|
||||
// instance down.
|
||||
//
|
||||
// Every query runs in one of two ways, decided per query:
|
||||
//
|
||||
// - Transpiled: the query is evaluated entirely inside ClickHouse and only
|
||||
// final (or near-final) per-group grid arrays come back, built on the
|
||||
// timeSeries*ToGrid aggregate functions (the supported ClickHouse floor
|
||||
// is >= 25.6, so they are assumed available).
|
||||
// - Engine: the stock promql engine evaluates over this package's native
|
||||
// storage.Querier. This is the path for everything not transpilable.
|
||||
//
|
||||
// Correctness is the constraint that shaped both paths: a PromQL result that
|
||||
// differs from upstream Prometheus is a lost user, so anything that cannot
|
||||
// reproduce engine semantics exactly falls back rather than approximate.
|
||||
// The rest of this comment is the PromQL -> SQL story, because that mapping
|
||||
// is where correctness is won or lost.
|
||||
//
|
||||
// # The evaluation model the SQL must reproduce
|
||||
//
|
||||
// A PromQL range query is an instant query evaluated at every grid point
|
||||
// t_i = start + i*step, i = 0..(end-start)/step. At each t_i:
|
||||
//
|
||||
// - an instant selector resolves to the latest sample in the left-open
|
||||
// lookback window (t_i - lookback, t_i], and to nothing when that latest
|
||||
// sample is a stale marker — even if older real samples sit inside the
|
||||
// window;
|
||||
// - a range selector [r] collects every sample in (t_i - r, t_i], stale
|
||||
// markers excluded;
|
||||
// - offset d shifts both windows to (t_i - d - w, t_i - d].
|
||||
//
|
||||
// The transpilation invariant follows from this: every transpiled construct
|
||||
// produces, per output series, one array with exactly one slot per grid
|
||||
// point — slot i holds the value at t_i, NULL means absent. This is what
|
||||
// makes composition correct, not just convenient: the engine evaluates
|
||||
// these operators independently per t_i, so any representation that gets
|
||||
// every slot right gets the whole query right, and spatial aggregation over
|
||||
// arrays is sound because it combines values that belong to the same t_i by
|
||||
// construction. Slot index i maps back to t_i = start + i*step at scan time
|
||||
// (toMatrix). Everything below is about filling those slots with exactly
|
||||
// the numbers the engine would compute — and each equivalence was validated
|
||||
// against the vendored engine on live data before its shape entered the
|
||||
// allowlist; anything unproven stays on the engine path.
|
||||
//
|
||||
// # Classification: finding what a statement can answer
|
||||
//
|
||||
// classify walks the parsed AST looking for "core units" — maximal subtrees
|
||||
// of the shape
|
||||
//
|
||||
// [agg by/without (...)] [fn(] selector[range] [offset d] [)] [op scalar]...
|
||||
//
|
||||
// classifyCore peels that chain from the outside in: an optional
|
||||
// sum/min/max/avg/count aggregation, then one of the allowlisted functions
|
||||
// or a bare instant selector, then the selector with its offset; on the way
|
||||
// out it accumulates number-literal arithmetic, comparisons (including
|
||||
// bool) and unary minus into a scalar-op pipeline. A node qualifies only if
|
||||
// its type, arguments and children are in the proven set — an allowlist, so
|
||||
// an overlooked construct becomes a fallback instead of a wrong number.
|
||||
//
|
||||
// Three unit kinds come out of this, each with its own SQL form:
|
||||
// unitRange (rate, irate, increase, delta, idelta over a range selector),
|
||||
// unitInstant (instant vector selection, bare or comparison-filtered) and
|
||||
// unitOverTime (avg/min/max/sum/count/last _over_time).
|
||||
//
|
||||
// If the entire tree is one unit, the plan is "full": the statement's rows
|
||||
// are the query result. Otherwise every maximal unit is cut out and replaced
|
||||
// in the expression with a synthetic selector __signoz_transpiled_N__, and
|
||||
// the rewritten expression runs in the engine over the units' materialized
|
||||
// results ("hybrid") — histogram_quantile, topk, or/and/unless and vector
|
||||
// matching keep exact engine semantics while their expensive inputs were
|
||||
// aggregated server-side.
|
||||
//
|
||||
// Classification refuses when exact semantics cannot be guaranteed
|
||||
// server-side: the @ modifier anywhere and default-resolution subqueries
|
||||
// (their resolution is a server runtime setting the transpiler cannot see);
|
||||
// steps or ranges that are not whole seconds (the grid functions take
|
||||
// whole-second parameters); grouping by or matching on __name__ in hybrid
|
||||
// plans (the synthetic name would leak into results); name-keeping units —
|
||||
// bare/comparison instant selectors and last_over_time keep their real
|
||||
// __name__ (keepsName), which substitution would replace, so they transpile
|
||||
// only as full plans; and every function outside the allowlist (changes,
|
||||
// resets, quantile_over_time, absent, native-histogram functions, ...).
|
||||
//
|
||||
// Units inside a fixed-resolution subquery evaluate on the subquery's own
|
||||
// grid instead of the query grid: epoch-aligned multiples of the resolution
|
||||
// strictly after outerStart - offset - range, ending at outer end - offset —
|
||||
// the exact derivation the engine uses, because a grid shifted by one step
|
||||
// changes which samples every window sees.
|
||||
//
|
||||
// # From one unit to one statement
|
||||
//
|
||||
// buildUnitSQL renders each unit as a single statement. For
|
||||
// sum by (pod) (rate(m{job="api"}[5m])) the skeleton is:
|
||||
//
|
||||
// SELECT gkey, sumForEach(grid) AS grid FROM (
|
||||
// SELECT series.gkey AS gkey,
|
||||
// timeSeriesRateToGrid(<start>, <end>, <step>, <range>)(fromUnixTimestamp64Milli(unix_milli), value) AS grid
|
||||
// FROM signoz_metrics.distributed_samples_v4 AS points
|
||||
// INNER JOIN (
|
||||
// SELECT fingerprint, <group key expr> AS gkey
|
||||
// FROM signoz_metrics.time_series_v4
|
||||
// WHERE <series predicates>
|
||||
// GROUP BY fingerprint, gkey
|
||||
// ) AS series ON points.fingerprint = series.fingerprint
|
||||
// WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified']
|
||||
// AND points.fingerprint IN (<matched fingerprints>)
|
||||
// AND unix_milli > <start - range> AND unix_milli <= <end>
|
||||
// AND bitAnd(flags, 1) = 0
|
||||
// GROUP BY points.fingerprint, series.gkey
|
||||
// ) GROUP BY gkey
|
||||
// SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1
|
||||
//
|
||||
// Reading it inside out:
|
||||
//
|
||||
// The time window is the selector's semantics verbatim: strict > on the
|
||||
// lower bound and <= on the upper is the left-open (t - w, t] rule, with the
|
||||
// whole window shifted by the offset. bitAnd(flags, 1) = 0 drops stale
|
||||
// markers, which PromQL excludes from range vectors.
|
||||
//
|
||||
// The inner GROUP BY computes one grid array per series.
|
||||
// timeSeriesRateToGrid(start, end, step, range) is a parametric aggregate:
|
||||
// fed (timestamp, value) pairs it produces Array(Nullable(Float64)) with one
|
||||
// slot per grid point. Correct because it implements the engine's
|
||||
// extrapolatedRate decision for decision — counter resets, the zero-point
|
||||
// clamp, the extrapolation thresholds, the >= 2 samples rule, the left-open
|
||||
// window — verified by feeding identical samples to both and comparing
|
||||
// slot for slot: the only difference ever observed is the last bit
|
||||
// (ClickHouse's C++ and Go round the same formula differently), which is
|
||||
// the floating-point floor, not a semantic gap. irate/delta/idelta map to
|
||||
// their own timeSeries*ToGrid functions with the same verification;
|
||||
// increase has no function of its own and is emitted as
|
||||
// arrayMap(x -> x * <range seconds>, <rate expr>), exact by definition —
|
||||
// extrapolatedRate computes the same extrapolated delta for both and
|
||||
// divides by the range only when isRate, so multiplying it back is the
|
||||
// identity, not an approximation. The grid parameters are rendered as
|
||||
// literals, not bound args — they are aggregate-function parameters — and
|
||||
// the experimental gate rides as a SETTINGS clause on the statement itself
|
||||
// so telemetrystore hooks cannot clobber it.
|
||||
//
|
||||
// The join annotates each series with its group key: toJSONString of the
|
||||
// sorted [label, value] pairs the unit projects, extracted from the stored
|
||||
// labels JSON. by keeps the listed labels, without excludes them plus
|
||||
// __name__, no aggregation keeps everything minus __name__ unless the unit
|
||||
// keeps its name — the engine's name-dropping rules. Correct as a grouping
|
||||
// key because the pairs are sorted and empty values are filtered: key
|
||||
// equality is then exactly label-set equality on the projection —
|
||||
// Prometheus treats an empty label value as the label being absent, and
|
||||
// stored attribute JSON can carry empties that must not split groups — and
|
||||
// the same canonical string parses back into the output label set
|
||||
// (labelsFromGroupKey).
|
||||
//
|
||||
// The outer GROUP BY is the spatial aggregation: sum/min/max/avg/count
|
||||
// by/without become the -ForEach combinators. Element-wise aggregation over
|
||||
// grid arrays is the engine's per-t_i aggregation, because slot i of every
|
||||
// input array refers to the same t_i; the combinators skip NULLs, which is
|
||||
// the engine aggregating only the series present at t_i, and an index where
|
||||
// every series is absent stays NULL. Two edges need explicit handling:
|
||||
// countForEach wraps in a mapping of 0 back to NULL, because a count over
|
||||
// an all-absent index is an absent point, not 0; and a unit without
|
||||
// aggregation still passes through maxForEach — the identity for the common
|
||||
// one-fingerprint group, and a deterministic NULL-skipping merge when a
|
||||
// regex __name__ selector collapses distinct metrics onto one projected
|
||||
// label set. One caveat is inherent: summation order over series differs
|
||||
// from the engine's, so spatial aggregates can differ in the last ULP —
|
||||
// float addition is not associative; no ordering reproduces the engine's
|
||||
// bit-exactly from inside a GROUP BY.
|
||||
//
|
||||
// # Instant selectors: staleness needs two aggregates
|
||||
//
|
||||
// unitInstant uses window = lookback and must reproduce the shadowing rule:
|
||||
// the point is absent when the latest in-window sample is a stale marker.
|
||||
// timeSeriesLastToGrid alone cannot express that — skipping stale rows in
|
||||
// WHERE would resurrect the older real sample the marker was written to
|
||||
// bury. So stale rows stay in the scan for this kind only, and the grid
|
||||
// expression compares three aggregates per slot:
|
||||
//
|
||||
// arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok),
|
||||
// timeSeriesLastToGrid(...)(ts, toFloat64(unix_milli)), -- last sample overall
|
||||
// timeSeriesLastToGridIf(...)(ts, toFloat64(unix_milli), bitAnd(flags, 1) = 0), -- last non-stale, its timestamp
|
||||
// timeSeriesLastToGridIf(...)(ts, value, bitAnd(flags, 1) = 0)) -- last non-stale, its value
|
||||
//
|
||||
// Correct by cases on a slot's window. No samples at all: both timestamp
|
||||
// aggregates are NULL, the slot is NULL — absent, as the engine says. Latest
|
||||
// sample non-stale: it is the latest overall and the latest non-stale, the
|
||||
// timestamps agree, the slot takes its value — the engine's pick. Latest
|
||||
// sample stale: the last-overall timestamp is the marker's, the
|
||||
// last-non-stale timestamp is older (or NULL when only markers are in
|
||||
// window), they disagree, the slot is NULL — the marker shadows, exactly
|
||||
// the engine's rule. Timestamps are unique per series (ingest dedups), so
|
||||
// timestamp equality identifies "the same sample" without ambiguity. The
|
||||
// -If combinator's applicability to these experimental aggregates was
|
||||
// probed before being trusted, not assumed.
|
||||
//
|
||||
// # Windowed *_over_time: fan-out instead of a grid function
|
||||
//
|
||||
// avg/min/max/sum/count _over_time aggregate every raw sample in the window,
|
||||
// and no timeSeries*ToGrid function computes them. (last_over_time is the
|
||||
// exception: the last sample of a range vector — stale markers excluded from
|
||||
// range vectors by PromQL, excluded here in WHERE — is exactly
|
||||
// timeSeriesLastToGrid.) Instead, each sample is fanned out to every grid
|
||||
// index whose window contains it:
|
||||
//
|
||||
// ARRAY JOIN range(toUInt64(greatest(0, intDiv(unix_milli - <start> + <step> - 1, <step>))),
|
||||
// toUInt64(least(<lastIdx>, intDiv(unix_milli + <range> - 1 - <start>, <step>)) + 1)) AS k
|
||||
//
|
||||
// Correct because the bounds solve the window condition for k. A sample at
|
||||
// ts contributes to slot k iff t_k - range < ts <= t_k. The right side
|
||||
// gives t_k >= ts, so the first index is ceil((ts - start)/step) — a sample
|
||||
// at exactly t_k belongs to k, the window is right-closed. The left side
|
||||
// gives t_k < ts + range, and with millisecond-integer timestamps that is
|
||||
// t_k <= ts + range - 1, so the last index is
|
||||
// floor((ts + range - 1 - start)/step) — a sample at exactly t_k - range is
|
||||
// excluded, the window is left-open. Clamped to the grid, the fan-out
|
||||
// therefore lands each sample in exactly the slots whose windows contain
|
||||
// it, and GROUP BY (fingerprint, k) with the plain aggregate (avg(value),
|
||||
// min(value), ...) computes per slot over precisely the engine's sample
|
||||
// multiset — the same numbers, since avg/min/max/sum/count are
|
||||
// order-insensitive on a given multiset (sum/avg up to summation order, the
|
||||
// float caveat above). A second level assembles the positional array with
|
||||
// groupArray + indexOf, mapping missing indices to NULL — groupArrayInsertAt
|
||||
// would coerce NULL defaults to 0, which is a value, not absence. The
|
||||
// group-key join happens at the initiator here, over rows already reduced
|
||||
// to per-(series, index); see the sharding section for why that costs
|
||||
// nothing.
|
||||
//
|
||||
// # Scalar ops, full plans, hybrid plans
|
||||
//
|
||||
// The scalar-op pipeline applies in Go to the returned arrays
|
||||
// (applyScalarOps), slot by slot: arithmetic operators compute, comparisons
|
||||
// filter (the slot keeps the vector-side value or becomes NULL) or return
|
||||
// 0/1 under bool. Correct trivially: it is the same float64 operation the
|
||||
// engine would apply to the same slot value, in the same operator order the
|
||||
// AST dictates — running it in Go instead of another SQL layer changes
|
||||
// where, not what.
|
||||
//
|
||||
// A full plan's arrays map straight to the result matrix. A hybrid plan
|
||||
// materializes each unit's arrays as synthetic series under its
|
||||
// __signoz_transpiled_N__ name and evaluates the rewritten expression over
|
||||
// a storage that serves synthetic names from memory and everything else
|
||||
// live. Substitution is sound because a unit's output is a plain instant
|
||||
// vector to the engine — same values at same timestamps under a different
|
||||
// name, and the name cannot matter: plans that group by or match on
|
||||
// __name__ were refused at classification, and name-keeping units are never
|
||||
// substituted. One subtlety makes it exact: stale markers are written at
|
||||
// absent grid points, because the engine's lookback would otherwise
|
||||
// resurrect a point from up to lookback earlier — the marker encodes
|
||||
// "absent here" the way the engine itself encodes it. Units evaluate
|
||||
// concurrently; each is one series lookup plus one grid statement. A step
|
||||
// of 0 is an instant query: a single evaluation at end.
|
||||
//
|
||||
// # Series lookup
|
||||
//
|
||||
// Both paths resolve matchers the same way, once per selector
|
||||
// (selectSeries): __name__ matchers translate to the metric_name column —
|
||||
// all four matcher types; the v1 client silently returned nothing for regex
|
||||
// metric names — and every other matcher to a JSONExtractString condition on
|
||||
// the labels column (applySeriesConditions). Regexes are anchored before
|
||||
// they reach match(): PromQL matchers match the whole value, ClickHouse
|
||||
// match() searches for a substring, and without anchoring =~"api" would
|
||||
// also select "x-api-y". An equality matcher against "" matches series
|
||||
// without the label, mirroring PromQL, because JSONExtractString returns ""
|
||||
// for missing keys. The series tables hold one row per (fingerprint, bucket)
|
||||
// at 1h/6h/1d/1w granularities; timeSeriesTableFor picks the table whose
|
||||
// bucket fits the window and rounds the window start down to the bucket
|
||||
// boundary. The resulting label sets drop what v1 leaked into results: the
|
||||
// synthetic fingerprint label (it would take part in without() grouping and
|
||||
// vector matching) and empty-valued labels. MaxFetchedSeries fails the
|
||||
// lookup with a typed invalid-input error past the ceiling — v1's behavior
|
||||
// for an oversized selector was to buffer everything and OOM, and a 4xx the
|
||||
// user can narrow beats a dead process serving nobody.
|
||||
//
|
||||
// # The engine path
|
||||
//
|
||||
// Queries that do not transpile run in the stock engine over this package's
|
||||
// storage.Querier, which is still not the v1 path. Samples are fetched per
|
||||
// selector using the engine's per-selector hints, not the query-wide union
|
||||
// window, so foo / foo offset 1d reads two narrow windows instead of the
|
||||
// widest one twice. Instant selectors of subquery-free queries fetch only
|
||||
// the last sample per step bucket (lastSamplePerStep): buckets anchor at the
|
||||
// selector's first evaluation timestamp — recovered from the hints as
|
||||
// hints.Start + lookback - 1ms, the inverse of how the engine derives
|
||||
// hints.Start — so bucket boundaries coincide with evaluation timestamps and
|
||||
// a non-final sample of a bucket can never be the latest sample in
|
||||
// (t - lookback, t] for any grid t. Real timestamps are preserved, so the
|
||||
// engine's own lookback and staleness handling stay exact. Range selectors
|
||||
// always fetch raw — every sample feeds the range function — and the
|
||||
// subquery-free proof travels in the context as prometheus.QueryTraits,
|
||||
// because subquery selectors evaluate at the subquery's step while the
|
||||
// hints carry the top-level step. Row assembly counts rows against
|
||||
// MaxFetchedSamples while scanning, keeps the first of consecutive equal
|
||||
// timestamps, maps stale flags to the engine's StaleNaN, and merges series
|
||||
// with identical label sets (sortAndMerge) — the engine assumes storages
|
||||
// never emit duplicates. A {job="rawsql", query="..."} selector bypasses all
|
||||
// of this and runs the query matcher's value verbatim.
|
||||
//
|
||||
// # Sharding
|
||||
//
|
||||
// samples_v4 and time_series_v4 (and all their rollups) shard on the same
|
||||
// key — cityHash64(env, temporality, metric_name, fingerprint) — so a
|
||||
// series' samples and catalog rows live on the same shard. The transpiled
|
||||
// statement above exploits that: the distributed samples table at the
|
||||
// top-level FROM makes ClickHouse rewrite the whole inner query per shard,
|
||||
// where the join against the shard-local series table and the per-series
|
||||
// grid aggregation run next to the data; the initiator only merges
|
||||
// aggregate states and applies the spatial -ForEach step. Same layout as
|
||||
// the telemetrymetrics statement builder. Fingerprint filters follow suit:
|
||||
// matched sets inline as sorted literals up to inlineFingerprintsLimit
|
||||
// (literals engage the samples primary key; sorting keeps statements
|
||||
// deterministic), beyond it the group-key join alone restricts — a
|
||||
// semi-join on the same predicates would only rescan the series table —
|
||||
// except the windowed *_over_time fan-out, which has no join and keeps a
|
||||
// shard-local IN subquery rather than expand every series of the metric.
|
||||
// The engine path's over-limit filter is the same shard-local subquery, not
|
||||
// a GLOBAL broadcast of the matched set. The temporality filter on every
|
||||
// samples statement is a semantic no-op — the matched fingerprints already
|
||||
// come from those temporalities — that engages the leading samples
|
||||
// primary-key column. Delta-temporality series stay invisible to PromQL
|
||||
// here exactly as they are in v1: the rollout gate is parity with v1, and
|
||||
// making Delta visible is its own change with its own semantics to design —
|
||||
// a Delta stream fed to rate() as-if-cumulative would be wrong, not just
|
||||
// new.
|
||||
//
|
||||
// # Observability
|
||||
//
|
||||
// Every statement carries a log_comment with
|
||||
// code.namespace=clickhouse-prometheus-v2 and code.function.name naming the
|
||||
// call site (selectSeries, selectSamples, transpiledUnit, LabelValues,
|
||||
// LabelNames), so this provider's work is attributable in system.query_log
|
||||
// without guessing from query text.
|
||||
package clickhouseprometheusv2
|
||||
@@ -0,0 +1,191 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The lastSamplePerStep correctness argument, executed: for instant selectors, keeping
|
||||
// only the last sample of every step bucket (bucket 0 = (start, firstEval],
|
||||
// bucket i = (firstEval+(i-1)·step, firstEval+i·step]) yields exactly the
|
||||
// same instant-vector selections as the raw samples, for every evaluation
|
||||
// timestamp on the grid. The engine picks the latest sample in
|
||||
// (t-lookback, t] per evaluation timestamp t and treats a stale marker as
|
||||
// absent; both behaviors are emulated here directly.
|
||||
|
||||
type tsample struct {
|
||||
ts int64
|
||||
value float64
|
||||
stale bool
|
||||
}
|
||||
|
||||
// engineSelect emulates the engine's instant-selector resolution at
|
||||
// evaluation timestamp t over samples ordered by timestamp: the latest sample
|
||||
// in (t-lookback, t], absent when none or when it is a stale marker.
|
||||
func engineSelect(samples []tsample, t, lookbackMs int64) (tsample, bool) {
|
||||
var picked tsample
|
||||
found := false
|
||||
for _, s := range samples {
|
||||
if s.ts > t-lookbackMs && s.ts <= t {
|
||||
picked = s
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found || picked.stale {
|
||||
return tsample{}, false
|
||||
}
|
||||
return picked, true
|
||||
}
|
||||
|
||||
// lastPerStep emulates the last-sample-per-step samples query: group samples into buckets and
|
||||
// keep only the last sample of each (ties keep either; ClickHouse argMax over
|
||||
// equal keys is unspecified, so generated timestamps are unique).
|
||||
func lastPerStep(samples []tsample, firstEvalMs, stepMs int64) []tsample {
|
||||
last := make(map[int64]tsample)
|
||||
for _, s := range samples {
|
||||
var bucket int64
|
||||
if stepMs > 0 && s.ts > firstEvalMs {
|
||||
bucket = (s.ts-firstEvalMs-1)/stepMs + 1
|
||||
}
|
||||
if cur, ok := last[bucket]; !ok || s.ts > cur.ts {
|
||||
last[bucket] = s
|
||||
}
|
||||
}
|
||||
out := make([]tsample, 0, len(last))
|
||||
for _, s := range last {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ts < out[j].ts })
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLastSamplePerStepEquivalence(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
|
||||
for caseIdx := 0; caseIdx < 2000; caseIdx++ {
|
||||
// Random query shape. Units are milliseconds but kept small so bucket
|
||||
// boundaries are hit often.
|
||||
stepMs := []int64{1, 2, 5, 7, 30, 60}[rng.Intn(6)]
|
||||
lookbackMs := []int64{1, 3, 5, 10, 45}[rng.Intn(5)]
|
||||
queryStart := int64(1000)
|
||||
numSteps := rng.Int63n(20)
|
||||
queryEnd := queryStart + numSteps*stepMs + rng.Int63n(stepMs) // grid may not divide the range
|
||||
|
||||
// Engine-derived selector window for instant selectors:
|
||||
// hints.Start = firstEval - (lookback - 1), hints.End = queryEnd.
|
||||
hintsStart := queryStart - (lookbackMs - 1)
|
||||
hintsEnd := queryEnd
|
||||
firstEval := hintsStart + lookbackMs - 1
|
||||
require.Equal(t, queryStart, firstEval)
|
||||
|
||||
// Random samples inside the fetch window [hints.Start, hints.End],
|
||||
// with unique timestamps and occasional stale markers. The sample
|
||||
// count is capped by the window size: timestamps are unique.
|
||||
windowSize := hintsEnd - hintsStart + 1
|
||||
numSamples := rng.Int63n(40)
|
||||
if numSamples > windowSize {
|
||||
numSamples = windowSize
|
||||
}
|
||||
seen := make(map[int64]bool)
|
||||
var samples []tsample
|
||||
for int64(len(samples)) < numSamples {
|
||||
ts := hintsStart + rng.Int63n(windowSize)
|
||||
if seen[ts] {
|
||||
continue
|
||||
}
|
||||
seen[ts] = true
|
||||
samples = append(samples, tsample{ts: ts, value: rng.Float64(), stale: rng.Intn(8) == 0})
|
||||
}
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i].ts < samples[j].ts })
|
||||
|
||||
reduced := lastPerStep(samples, firstEval, stepMs)
|
||||
|
||||
desc := fmt.Sprintf("case=%d step=%d lookback=%d start=%d end=%d samples=%d",
|
||||
caseIdx, stepMs, lookbackMs, queryStart, queryEnd, len(samples))
|
||||
|
||||
for evalTs := queryStart; evalTs <= queryEnd; evalTs += stepMs {
|
||||
rawPick, rawOK := engineSelect(samples, evalTs, lookbackMs)
|
||||
reducedPick, reducedOK := engineSelect(reduced, evalTs, lookbackMs)
|
||||
|
||||
require.Equal(t, rawOK, reducedOK, "%s eval=%d presence mismatch", desc, evalTs)
|
||||
if rawOK {
|
||||
require.Equal(t, rawPick, reducedPick, "%s eval=%d sample mismatch", desc, evalTs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instant queries (step 0) evaluate once at firstEval == hints.End; lastSamplePerStep
|
||||
// collapses to a single bucket over the whole window.
|
||||
func TestLastSamplePerStepEquivalenceInstantQuery(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(7))
|
||||
|
||||
for caseIdx := 0; caseIdx < 500; caseIdx++ {
|
||||
lookbackMs := []int64{1, 3, 5, 10, 45}[rng.Intn(5)]
|
||||
evalTs := int64(1000)
|
||||
hintsStart := evalTs - (lookbackMs - 1)
|
||||
hintsEnd := evalTs
|
||||
firstEval := hintsStart + lookbackMs - 1
|
||||
require.Equal(t, evalTs, firstEval)
|
||||
|
||||
windowSize := hintsEnd - hintsStart + 1
|
||||
numSamples := rng.Int63n(10)
|
||||
if numSamples > windowSize {
|
||||
numSamples = windowSize
|
||||
}
|
||||
seen := make(map[int64]bool)
|
||||
var samples []tsample
|
||||
for int64(len(samples)) < numSamples {
|
||||
ts := hintsStart + rng.Int63n(windowSize)
|
||||
if seen[ts] {
|
||||
continue
|
||||
}
|
||||
seen[ts] = true
|
||||
samples = append(samples, tsample{ts: ts, value: rng.Float64(), stale: rng.Intn(4) == 0})
|
||||
}
|
||||
sort.Slice(samples, func(i, j int) bool { return samples[i].ts < samples[j].ts })
|
||||
|
||||
reduced := lastPerStep(samples, firstEval, 0)
|
||||
require.LessOrEqual(t, len(reduced), 1, "instant reduction must keep at most one sample")
|
||||
|
||||
rawPick, rawOK := engineSelect(samples, evalTs, lookbackMs)
|
||||
reducedPick, reducedOK := engineSelect(reduced, evalTs, lookbackMs)
|
||||
require.Equal(t, rawOK, reducedOK, "case=%d presence mismatch", caseIdx)
|
||||
if rawOK {
|
||||
require.Equal(t, rawPick, reducedPick, "case=%d sample mismatch", caseIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stale marker that is the latest sample of its bucket must shadow older
|
||||
// samples: the engine sees the marker and reports the series absent, exactly
|
||||
// as with raw samples. Pre-filtering stale rows would instead resurrect the
|
||||
// older sample.
|
||||
func TestLastSamplePerStepKeepsStaleShadowing(t *testing.T) {
|
||||
lookbackMs := int64(10)
|
||||
stepMs := int64(5)
|
||||
queryStart := int64(1000)
|
||||
|
||||
samples := []tsample{
|
||||
{ts: 998, value: 1.0}, // bucket 0
|
||||
{ts: 999, stale: true}, // bucket 0: marker shadows 998
|
||||
{ts: 1003, value: 2.0}, // bucket 1
|
||||
{ts: 1004, stale: true}, // bucket 1: marker shadows 1003
|
||||
{ts: 1008, value: 3.0, stale: false}, // bucket 2
|
||||
}
|
||||
firstEval := queryStart
|
||||
reduced := lastPerStep(samples, firstEval, stepMs)
|
||||
|
||||
for evalTs := queryStart; evalTs <= queryStart+2*stepMs; evalTs += stepMs {
|
||||
rawPick, rawOK := engineSelect(samples, evalTs, lookbackMs)
|
||||
reducedPick, reducedOK := engineSelect(reduced, evalTs, lookbackMs)
|
||||
require.Equal(t, rawOK, reducedOK, "eval=%d", evalTs)
|
||||
if rawOK {
|
||||
require.Equal(t, rawPick, reducedPick, "eval=%d", evalTs)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
pkg/prometheus/clickhouseprometheusv2/provider.go
Normal file
84
pkg/prometheus/clickhouseprometheusv2/provider.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
)
|
||||
|
||||
// Provider ties the package together: its own engine and parser, the
|
||||
// ClickHouse client behind the native storage.Querier, and the transpiler
|
||||
// executor. See the package documentation for what runs where and why. It is
|
||||
// exported as a concrete type — pkg/querier holds it directly for shadow
|
||||
// comparison and pinned serving, and an interface with a single
|
||||
// implementation would only hide that dependency.
|
||||
type Provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
engine *prometheus.Engine
|
||||
parser prometheus.Parser
|
||||
client *client
|
||||
executor *executor
|
||||
}
|
||||
|
||||
var (
|
||||
_ prometheus.Prometheus = (*Provider)(nil)
|
||||
_ prometheus.StatementCapturer = (*Provider)(nil)
|
||||
)
|
||||
|
||||
func NewFactory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[prometheus.Prometheus, prometheus.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("clickhousev2"), func(ctx context.Context, providerSettings factory.ProviderSettings, config prometheus.Config) (prometheus.Prometheus, error) {
|
||||
return New(ctx, providerSettings, config, telemetryStore)
|
||||
})
|
||||
}
|
||||
|
||||
func New(_ context.Context, providerSettings factory.ProviderSettings, config prometheus.Config, telemetryStore telemetrystore.TelemetryStore) (*Provider, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2")
|
||||
|
||||
engine := prometheus.NewEngine(settings.Logger(), config)
|
||||
parser := prometheus.NewParser()
|
||||
client := newClient(settings, telemetryStore, config)
|
||||
|
||||
return &Provider{
|
||||
settings: settings,
|
||||
engine: engine,
|
||||
parser: parser,
|
||||
client: client,
|
||||
executor: &executor{client: client, engine: engine, parser: parser},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TryExecuteRange evaluates transpilable query shapes directly in ClickHouse
|
||||
// (see transpiler.go). ok=false means the shape is not transpilable and the
|
||||
// caller should evaluate through Engine over Storage instead.
|
||||
func (p *Provider) TryExecuteRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
|
||||
return p.executor.TryExecuteRange(ctx, query, start, end, step)
|
||||
}
|
||||
|
||||
func (p *Provider) Engine() *prometheus.Engine {
|
||||
return p.engine
|
||||
}
|
||||
|
||||
func (p *Provider) Parser() prometheus.Parser {
|
||||
return p.parser
|
||||
}
|
||||
|
||||
func (p *Provider) Storage() storage.Queryable {
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &querier{mint: mint, maxt: maxt, client: p.client}, nil
|
||||
}
|
||||
|
||||
// CapturingStorage implements prometheus.StatementCapturer: a storage that
|
||||
// records each selector's SQL without executing it, for the preview path.
|
||||
// A fresh recorder per call keeps concurrent dry-runs isolated.
|
||||
func (p *Provider) CapturingStorage() (storage.Queryable, prometheus.StatementRecorder) {
|
||||
recorder := &statementRecorder{}
|
||||
return &captureQueryable{client: p.client, recorder: recorder}, recorder
|
||||
}
|
||||
233
pkg/prometheus/clickhouseprometheusv2/querier.go
Normal file
233
pkg/prometheus/clickhouseprometheusv2/querier.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// defaultLookbackDelta mirrors promql's default when the config leaves the
|
||||
// lookback unset; the engine and the storage must agree on it for
|
||||
// last-sample-per-step bucket anchoring.
|
||||
const defaultLookbackDelta = 5 * time.Minute
|
||||
|
||||
// querier is a native storage.Querier over ClickHouse. Unlike v1 it does not
|
||||
// round-trip through the remote-read protobuf machinery: Select builds SQL
|
||||
// directly from the matchers and hints, and the result set is assembled once
|
||||
// into compact series.
|
||||
type querier struct {
|
||||
mint, maxt int64
|
||||
client *client
|
||||
}
|
||||
|
||||
var _ storage.Querier = (*querier)(nil)
|
||||
|
||||
func (q *querier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
if rawQuery, ok := rawSQLQuery(matchers); ok {
|
||||
_, end := q.window(hints)
|
||||
list, err := q.client.queryRaw(ctx, rawQuery, end)
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
if sortSeries {
|
||||
sort.Slice(list, func(i, j int) bool { return labels.Compare(list[i].lset, list[j].lset) < 0 })
|
||||
}
|
||||
return newSeriesSet(list)
|
||||
}
|
||||
|
||||
start, end := q.window(hints)
|
||||
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(start, end, matchers)
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
lookup, err := q.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return storage.EmptySeriesSet()
|
||||
}
|
||||
|
||||
list, err := q.fetchSamples(ctx, start, end, matchers, lookup, q.lastSamplePerStepFor(ctx, hints))
|
||||
if err != nil {
|
||||
return storage.ErrSeriesSet(err)
|
||||
}
|
||||
|
||||
// Sorting doubles as duplicate-label-set detection, which the engine
|
||||
// depends on storages never emitting; the cost is on series count, not
|
||||
// samples.
|
||||
list = sortAndMerge(list)
|
||||
return newSeriesSet(list)
|
||||
}
|
||||
|
||||
// LabelValues returns the values of a label across series matching the
|
||||
// matchers within the querier window.
|
||||
func (q *querier) LabelValues(ctx context.Context, name string, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
if name == metricNameLabel {
|
||||
sb.Select("DISTINCT metric_name AS value")
|
||||
} else {
|
||||
sb.Select(fmt.Sprintf("DISTINCT JSONExtractString(labels, %s) AS value", sb.Var(name)))
|
||||
}
|
||||
adjustedStart, table := timeSeriesTableFor(q.mint, q.maxt)
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, q.maxt, matchers); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sb.Where("value != ''")
|
||||
if hints != nil && hints.Limit > 0 {
|
||||
sb.Limit(hints.Limit)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
values, err := q.selectStrings(ctx, "LabelValues", query, args)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slices.Sort(values)
|
||||
return values, nil, nil
|
||||
}
|
||||
|
||||
// LabelNames returns the label names present on series matching the matchers
|
||||
// within the querier window.
|
||||
func (q *querier) LabelNames(ctx context.Context, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("DISTINCT arrayJoin(JSONExtractKeys(labels)) AS name")
|
||||
adjustedStart, table := timeSeriesTableFor(q.mint, q.maxt)
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, q.maxt, matchers); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if hints != nil && hints.Limit > 0 {
|
||||
sb.Limit(hints.Limit)
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
names, err := q.selectStrings(ctx, "LabelNames", query, args)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
slices.Sort(names)
|
||||
return names, nil, nil
|
||||
}
|
||||
|
||||
func (q *querier) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// window returns the per-selector fetch window. The engine sends per-selector
|
||||
// bounds in the hints (already adjusted for offset, @, range and lookback);
|
||||
// they are always at least as tight as the querier-level mint/maxt, which
|
||||
// span the union of all selectors in the query.
|
||||
func (q *querier) window(hints *storage.SelectHints) (int64, int64) {
|
||||
if hints != nil && hints.Start != 0 && hints.End != 0 && hints.Start <= hints.End {
|
||||
return hints.Start, hints.End
|
||||
}
|
||||
return q.mint, q.maxt
|
||||
}
|
||||
|
||||
// lastSamplePerStepFor decides whether the fetch can keep only the last
|
||||
// sample per step bucket, and computes the bucket parameters. Requirements:
|
||||
// - the call site attached QueryTraits proving the query has no subquery
|
||||
// (subquery selectors evaluate at the subquery's own step, but hints
|
||||
// carry the top-level step);
|
||||
// - the selector is an instant selector (hints.Range == 0); range selectors
|
||||
// need every raw sample in the window;
|
||||
// - per-selector hints are present.
|
||||
//
|
||||
// The engine derives hints.Start for instant selectors as
|
||||
// firstEval - (lookback - 1ms), so the first evaluation timestamp is
|
||||
// recovered as hints.Start + lookback - 1ms. Bucket boundaries then coincide
|
||||
// with evaluation timestamps, which is what makes keeping only the last
|
||||
// sample per bucket lossless.
|
||||
func (q *querier) lastSamplePerStepFor(ctx context.Context, hints *storage.SelectHints) *lastSamplePerStep {
|
||||
if hints == nil || hints.Range != 0 || hints.Start <= 0 {
|
||||
return nil
|
||||
}
|
||||
traits, ok := prometheus.QueryTraitsFromContext(ctx)
|
||||
if !ok || !traits.SubqueryFree {
|
||||
return nil
|
||||
}
|
||||
firstEval := hints.Start + q.client.lookbackMs - 1
|
||||
if firstEval > hints.End {
|
||||
// Defensive: never anchor a bucket past the window.
|
||||
firstEval = hints.End
|
||||
}
|
||||
return &lastSamplePerStep{firstEvalMs: firstEval, stepMs: hints.Step}
|
||||
}
|
||||
|
||||
// fetchSamples runs the samples query for the matched series. Small sets
|
||||
// inline the fingerprints as sorted uint64 literals — literals engage the
|
||||
// samples primary key, and sorting keeps the statement deterministic for
|
||||
// logging and tests. Larger sets re-run the series predicates as a
|
||||
// shard-local IN subquery instead: inlining hundreds of thousands of
|
||||
// literals makes the statement itself the bottleneck, while the subquery is
|
||||
// a cheap primary-key scan on each shard's own series table (see
|
||||
// localTimeSeriesTable for why that is complete).
|
||||
func (q *querier) fetchSamples(ctx context.Context, start, end int64, matchers []*labels.Matcher, lookup *seriesLookup, lastPerStep *lastSamplePerStep) ([]*series, error) {
|
||||
var fingerprints []uint64
|
||||
if len(lookup.fingerprints) <= inlineFingerprintsLimit {
|
||||
fingerprints = make([]uint64, 0, len(lookup.fingerprints))
|
||||
for fp := range lookup.fingerprints {
|
||||
fingerprints = append(fingerprints, fp)
|
||||
}
|
||||
slices.Sort(fingerprints)
|
||||
}
|
||||
query, args, err := buildSamplesQuery(start, end, lookup.metricNames, fingerprints, matchers, lastPerStep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.client.selectSamples(ctx, query, args, lookup)
|
||||
}
|
||||
|
||||
func (q *querier) selectStrings(ctx context.Context, fn, query string, args []any) ([]string, error) {
|
||||
ctx = q.client.withContext(ctx, fn)
|
||||
rows, err := q.client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
var v string
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rawSQLQuery detects the {job="rawsql", query="..."} escape hatch.
|
||||
func rawSQLQuery(matchers []*labels.Matcher) (string, bool) {
|
||||
if len(matchers) != 2 {
|
||||
return "", false
|
||||
}
|
||||
var hasJob bool
|
||||
var query string
|
||||
for _, m := range matchers {
|
||||
if m.Type == labels.MatchEqual && m.Name == "job" && m.Value == "rawsql" {
|
||||
hasJob = true
|
||||
}
|
||||
if m.Type == labels.MatchEqual && m.Name == "query" {
|
||||
query = m.Value
|
||||
}
|
||||
}
|
||||
if hasJob && query != "" {
|
||||
return query, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
221
pkg/prometheus/clickhouseprometheusv2/querier_test.go
Normal file
221
pkg/prometheus/clickhouseprometheusv2/querier_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
seriesCols = []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
}
|
||||
samplesCols = []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "unix_milli", Type: "Int64"},
|
||||
{Name: "value", Type: "Float64"},
|
||||
{Name: "flags", Type: "UInt32"},
|
||||
}
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T, cfg prometheus.ClickhouseV2Config) (*client, *telemetrystoretest.Provider) {
|
||||
t.Helper()
|
||||
store := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
|
||||
settings := factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "clickhouseprometheusv2_test")
|
||||
promCfg := prometheus.Config{ClickhouseV2: cfg}
|
||||
return newClient(settings, store, promCfg), store
|
||||
}
|
||||
|
||||
func testMatchers(t *testing.T) []*labels.Matcher {
|
||||
t.Helper()
|
||||
return []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "cpu_usage"),
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerierSelectRawPath(t *testing.T) {
|
||||
c, store := newTestClient(t, prometheus.ClickhouseV2Config{})
|
||||
q := &querier{mint: 1000, maxt: 2000, client: c}
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("cpu_usage", int64(0), int64(2000), "job", "api").WillReturnRows(cmock.NewRows(seriesCols, [][]any{
|
||||
{uint64(42), `{"__name__":"cpu_usage","job":"api","instance":"a"}`},
|
||||
{uint64(7), `{"__name__":"cpu_usage","job":"api","instance":"b"}`},
|
||||
}))
|
||||
// Inline fingerprints (sorted), raw samples: no traits in ctx -> no
|
||||
// last-sample-per-step reduction.
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, unix_milli, value, flags FROM signoz_metrics.distributed_samples_v4 WHERE metric_name = \\? AND temporality IN \\['Cumulative', 'Unspecified'\\] AND fingerprint IN \\(7, 42\\)").
|
||||
WithArgs("cpu_usage", int64(1000), int64(2000)).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
|
||||
{uint64(7), int64(1100), 1.5, uint32(0)},
|
||||
{uint64(7), int64(1200), 2.5, uint32(0)},
|
||||
{uint64(42), int64(1100), 3.5, uint32(1)}, // stale marker
|
||||
}))
|
||||
|
||||
hints := &storage.SelectHints{Start: 1000, End: 2000, Step: 60_000}
|
||||
set := q.Select(context.Background(), false, hints, testMatchers(t)...)
|
||||
|
||||
var got []*series
|
||||
for set.Next() {
|
||||
got = append(got, set.At().(*series))
|
||||
}
|
||||
require.NoError(t, set.Err())
|
||||
require.Len(t, got, 2)
|
||||
|
||||
// Sorted by labels: instance=a (fp 42) before instance=b (fp 7).
|
||||
assert.Equal(t, "a", got[0].lset.Get("instance"))
|
||||
require.Len(t, got[0].ts, 1)
|
||||
assert.True(t, got[0].vs[0] != got[0].vs[0], "stale marker must be NaN") //nolint:testifylint
|
||||
|
||||
assert.Equal(t, "b", got[1].lset.Get("instance"))
|
||||
assert.Equal(t, []int64{1100, 1200}, got[1].ts)
|
||||
assert.Equal(t, []float64{1.5, 2.5}, got[1].vs)
|
||||
|
||||
// No fingerprint label injected.
|
||||
assert.Empty(t, got[0].lset.Get("fingerprint"))
|
||||
}
|
||||
|
||||
// Wrong gating silently corrupts range functions (a rate over reduced
|
||||
// samples loses points), so the decision logic is pinned here even though
|
||||
// the helper is unexported: the integration suite would catch it too, but
|
||||
// with far worse failure locality.
|
||||
func TestLastSamplePerStepFor(t *testing.T) {
|
||||
c, _ := newTestClient(t, prometheus.ClickhouseV2Config{})
|
||||
q := &querier{mint: 0, maxt: 2000, client: c}
|
||||
traitsCtx := prometheus.NewContextWithQueryTraits(context.Background(), prometheus.QueryTraits{SubqueryFree: true})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
hints *storage.SelectHints
|
||||
want *lastSamplePerStep
|
||||
}{
|
||||
{"no traits in context stays raw", context.Background(), &storage.SelectHints{Start: 1000, End: 2000, Step: 60_000}, nil},
|
||||
{"subquery in the query stays raw", prometheus.NewContextWithQueryTraits(context.Background(), prometheus.QueryTraits{SubqueryFree: false}), &storage.SelectHints{Start: 1000, End: 2000, Step: 60_000}, nil},
|
||||
{"range selector stays raw", traitsCtx, &storage.SelectHints{Start: 1000, End: 2000, Step: 60_000, Range: 300_000}, nil},
|
||||
{"instant selector reduces, anchored at first eval", traitsCtx, &storage.SelectHints{Start: 1000, End: 2_000_000, Step: 60_000}, &lastSamplePerStep{firstEvalMs: 1000 + c.lookbackMs - 1, stepMs: 60_000}},
|
||||
{"anchor never passes the window end", traitsCtx, &storage.SelectHints{Start: 1000, End: 2000, Step: 60_000}, &lastSamplePerStep{firstEvalMs: 2000, stepMs: 60_000}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, q.lastSamplePerStepFor(tt.ctx, tt.hints))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuerierSelectSeriesBudget(t *testing.T) {
|
||||
c, store := newTestClient(t, prometheus.ClickhouseV2Config{MaxFetchedSeries: 1})
|
||||
q := &querier{mint: 1000, maxt: 2000, client: c}
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("cpu_usage", int64(0), int64(2000), "job", "api").WillReturnRows(cmock.NewRows(seriesCols, [][]any{
|
||||
{uint64(1), `{"__name__":"cpu_usage","instance":"a"}`},
|
||||
{uint64(2), `{"__name__":"cpu_usage","instance":"b"}`},
|
||||
}))
|
||||
|
||||
set := q.Select(context.Background(), false, &storage.SelectHints{Start: 1000, End: 2000}, testMatchers(t)...)
|
||||
assert.False(t, set.Next())
|
||||
require.Error(t, set.Err())
|
||||
assert.True(t, errors.Ast(set.Err(), errors.TypeInvalidInput), "budget error must be typed invalid input, got %v", set.Err())
|
||||
}
|
||||
|
||||
func TestQuerierSelectSamplesBudget(t *testing.T) {
|
||||
c, store := newTestClient(t, prometheus.ClickhouseV2Config{MaxFetchedSamples: 2})
|
||||
q := &querier{mint: 1000, maxt: 2000, client: c}
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("cpu_usage", int64(0), int64(2000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{
|
||||
{uint64(7), `{"__name__":"cpu_usage"}`},
|
||||
}))
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, unix_milli, value, flags").
|
||||
WithArgs("cpu_usage", int64(1000), int64(2000)).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{
|
||||
{uint64(7), int64(1100), 1.0, uint32(0)},
|
||||
{uint64(7), int64(1200), 2.0, uint32(0)},
|
||||
{uint64(7), int64(1300), 3.0, uint32(0)},
|
||||
}))
|
||||
|
||||
set := q.Select(context.Background(), false, &storage.SelectHints{Start: 1000, End: 2000},
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "cpu_usage"))
|
||||
assert.False(t, set.Next())
|
||||
require.Error(t, set.Err())
|
||||
assert.True(t, errors.Ast(set.Err(), errors.TypeInvalidInput))
|
||||
}
|
||||
|
||||
func TestQuerierSelectSubqueryFilterOverInlineLimit(t *testing.T) {
|
||||
c, store := newTestClient(t, prometheus.ClickhouseV2Config{})
|
||||
q := &querier{mint: 1000, maxt: 2000, client: c}
|
||||
|
||||
seriesRows := make([][]any, inlineFingerprintsLimit+1)
|
||||
for i := range seriesRows {
|
||||
seriesRows[i] = []any{uint64(i + 1), fmt.Sprintf(`{"__name__":"cpu_usage","instance":"i%d"}`, i)}
|
||||
}
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("cpu_usage", int64(0), int64(2000), "job", "api").WillReturnRows(cmock.NewRows(seriesCols, seriesRows))
|
||||
// The over-limit samples query embeds the semi-join against the
|
||||
// shard-local series table (fingerprint co-locality), not a GLOBAL
|
||||
// broadcast; args follow placeholder order — samples metric name, then
|
||||
// the semi-join's series predicates, then the samples window bounds.
|
||||
store.Mock().ExpectQuery("fingerprint IN \\(SELECT fingerprint FROM signoz_metrics\\.time_series_v4").
|
||||
WithArgs("cpu_usage", "cpu_usage", int64(0), int64(2000), "job", "api", int64(1000), int64(2000)).
|
||||
WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))
|
||||
|
||||
set := q.Select(context.Background(), false, &storage.SelectHints{Start: 1000, End: 2000}, testMatchers(t)...)
|
||||
assert.False(t, set.Next())
|
||||
require.NoError(t, set.Err())
|
||||
}
|
||||
|
||||
func TestQuerierSelectRawSQLPassthrough(t *testing.T) {
|
||||
c, store := newTestClient(t, prometheus.ClickhouseV2Config{})
|
||||
q := &querier{mint: 1000, maxt: 2000, client: c}
|
||||
|
||||
rawCols := []cmock.ColumnType{
|
||||
{Name: "le", Type: "String"},
|
||||
{Name: "value", Type: "Float64"},
|
||||
}
|
||||
store.Mock().ExpectQuery("SELECT le, avg\\(v\\) AS value FROM t").WillReturnRows(cmock.NewRows(rawCols, [][]any{
|
||||
{"0.5", 12.5},
|
||||
}))
|
||||
|
||||
set := q.Select(context.Background(), false, &storage.SelectHints{Start: 1000, End: 2000},
|
||||
mustMatcher(t, labels.MatchEqual, "job", "rawsql"),
|
||||
mustMatcher(t, labels.MatchEqual, "query", "SELECT le, avg(v) AS value FROM t"),
|
||||
)
|
||||
|
||||
require.True(t, set.Next())
|
||||
s := set.At()
|
||||
assert.Equal(t, "0.5", s.Labels().Get("le"))
|
||||
it := s.Iterator(nil)
|
||||
require.NotNil(t, it)
|
||||
_, v := func() (int64, float64) { it.Next(); return it.At() }()
|
||||
assert.Equal(t, 12.5, v)
|
||||
assert.False(t, set.Next())
|
||||
}
|
||||
|
||||
func TestCaptureQuerierRecordsWithoutExecuting(t *testing.T) {
|
||||
c, _ := newTestClient(t, prometheus.ClickhouseV2Config{})
|
||||
recorder := &statementRecorder{}
|
||||
cq := &captureQuerier{querier: querier{mint: 1000, maxt: 2000, client: c}, recorder: recorder}
|
||||
|
||||
ctx := prometheus.NewContextWithQueryTraits(context.Background(), prometheus.QueryTraits{SubqueryFree: true})
|
||||
set := cq.Select(ctx, false, &storage.SelectHints{Start: 1000, End: 2000, Step: 60_000}, testMatchers(t)...)
|
||||
assert.False(t, set.Next())
|
||||
require.NoError(t, set.Err())
|
||||
|
||||
statements := recorder.Statements()
|
||||
require.Len(t, statements, 1)
|
||||
assert.Contains(t, statements[0].Query, "IN (SELECT fingerprint FROM signoz_metrics.time_series_v4")
|
||||
assert.Contains(t, statements[0].Query, "argMax(value, unix_milli)")
|
||||
}
|
||||
184
pkg/prometheus/clickhouseprometheusv2/seriesset.go
Normal file
184
pkg/prometheus/clickhouseprometheusv2/seriesset.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/prometheus/prometheus/model/histogram"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/prometheus/prometheus/tsdb/chunkenc"
|
||||
"github.com/prometheus/prometheus/util/annotations"
|
||||
)
|
||||
|
||||
// series is one time series with samples stored as parallel slices, ordered
|
||||
// by timestamp. The compact layout avoids per-sample allocations and keeps
|
||||
// iteration cache friendly.
|
||||
type series struct {
|
||||
lset labels.Labels
|
||||
ts []int64
|
||||
vs []float64
|
||||
}
|
||||
|
||||
var _ storage.Series = (*series)(nil)
|
||||
|
||||
func (s *series) Labels() labels.Labels {
|
||||
return s.lset
|
||||
}
|
||||
|
||||
func (s *series) Iterator(it chunkenc.Iterator) chunkenc.Iterator {
|
||||
if fit, ok := it.(*floatIterator); ok {
|
||||
fit.reset(s)
|
||||
return fit
|
||||
}
|
||||
fit := &floatIterator{}
|
||||
fit.reset(s)
|
||||
return fit
|
||||
}
|
||||
|
||||
// floatIterator implements chunkenc.Iterator over a series' sample slices.
|
||||
type floatIterator struct {
|
||||
s *series
|
||||
i int
|
||||
}
|
||||
|
||||
var _ chunkenc.Iterator = (*floatIterator)(nil)
|
||||
|
||||
func (it *floatIterator) reset(s *series) {
|
||||
it.s = s
|
||||
it.i = -1
|
||||
}
|
||||
|
||||
func (it *floatIterator) Next() chunkenc.ValueType {
|
||||
it.i++
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
|
||||
func (it *floatIterator) Seek(t int64) chunkenc.ValueType { //nolint:govet // stdmethods flags io.Seeker; this is chunkenc.Iterator's Seek
|
||||
if it.i < 0 {
|
||||
it.i = 0
|
||||
}
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
// The current position, once valid, must not move backwards.
|
||||
if it.s.ts[it.i] >= t {
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
it.i += sort.Search(len(it.s.ts)-it.i, func(j int) bool {
|
||||
return it.s.ts[it.i+j] >= t
|
||||
})
|
||||
if it.i >= len(it.s.ts) {
|
||||
return chunkenc.ValNone
|
||||
}
|
||||
return chunkenc.ValFloat
|
||||
}
|
||||
|
||||
func (it *floatIterator) At() (int64, float64) {
|
||||
return it.s.ts[it.i], it.s.vs[it.i]
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtHistogram(*histogram.Histogram) (int64, *histogram.Histogram) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtFloatHistogram(*histogram.FloatHistogram) (int64, *histogram.FloatHistogram) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (it *floatIterator) AtT() int64 {
|
||||
return it.s.ts[it.i]
|
||||
}
|
||||
|
||||
// AtST returns the current start timestamp; not tracked by this storage.
|
||||
func (it *floatIterator) AtST() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (it *floatIterator) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seriesSet iterates a fully materialized, label-sorted list of series.
|
||||
type seriesSet struct {
|
||||
series []*series
|
||||
i int
|
||||
}
|
||||
|
||||
var _ storage.SeriesSet = (*seriesSet)(nil)
|
||||
|
||||
func newSeriesSet(list []*series) *seriesSet {
|
||||
return &seriesSet{series: list, i: -1}
|
||||
}
|
||||
|
||||
func (s *seriesSet) Next() bool {
|
||||
s.i++
|
||||
return s.i < len(s.series)
|
||||
}
|
||||
|
||||
func (s *seriesSet) At() storage.Series {
|
||||
return s.series[s.i]
|
||||
}
|
||||
|
||||
func (s *seriesSet) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *seriesSet) Warnings() annotations.Annotations {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sortAndMerge orders series by label set and merges series whose label sets
|
||||
// are identical. Distinct fingerprints can carry identical label sets (e.g.
|
||||
// series differing only in a non-label dimension); Prometheus storages never
|
||||
// expose duplicate label sets to the engine, so merge their samples by
|
||||
// timestamp, keeping the first sample on ties.
|
||||
func sortAndMerge(list []*series) []*series {
|
||||
if len(list) < 2 {
|
||||
return list
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return labels.Compare(list[i].lset, list[j].lset) < 0
|
||||
})
|
||||
out := list[:1]
|
||||
for _, s := range list[1:] {
|
||||
last := out[len(out)-1]
|
||||
if labels.Compare(last.lset, s.lset) != 0 {
|
||||
out = append(out, s)
|
||||
continue
|
||||
}
|
||||
merged := mergeSamples(last, s)
|
||||
out[len(out)-1] = merged
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeSamples(a, b *series) *series {
|
||||
ts := make([]int64, 0, len(a.ts)+len(b.ts))
|
||||
vs := make([]float64, 0, len(a.ts)+len(b.ts))
|
||||
i, j := 0, 0
|
||||
for i < len(a.ts) && j < len(b.ts) {
|
||||
switch {
|
||||
case a.ts[i] < b.ts[j]:
|
||||
ts = append(ts, a.ts[i])
|
||||
vs = append(vs, a.vs[i])
|
||||
i++
|
||||
case a.ts[i] > b.ts[j]:
|
||||
ts = append(ts, b.ts[j])
|
||||
vs = append(vs, b.vs[j])
|
||||
j++
|
||||
default:
|
||||
ts = append(ts, a.ts[i])
|
||||
vs = append(vs, a.vs[i])
|
||||
i++
|
||||
j++
|
||||
}
|
||||
}
|
||||
ts = append(ts, a.ts[i:]...)
|
||||
vs = append(vs, a.vs[i:]...)
|
||||
ts = append(ts, b.ts[j:]...)
|
||||
vs = append(vs, b.vs[j:]...)
|
||||
return &series{lset: a.lset, ts: ts, vs: vs}
|
||||
}
|
||||
199
pkg/prometheus/clickhouseprometheusv2/sql.go
Normal file
199
pkg/prometheus/clickhouseprometheusv2/sql.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
)
|
||||
|
||||
// inlineFingerprintsLimit is the largest matched-series count inlined into
|
||||
// the samples query as literals. Literals engage the samples primary key and
|
||||
// avoid a second series-table scan; past a few thousand the statement itself
|
||||
// becomes the cost, and the shard-local subquery filter wins. Not
|
||||
// configurable: the crossover depends on statement parsing, not on any
|
||||
// property of a deployment an operator could know better.
|
||||
const inlineFingerprintsLimit = 5_000
|
||||
|
||||
// buildSeriesQuery renders the series lookup: one row per matched fingerprint
|
||||
// with its labels.
|
||||
func buildSeriesQuery(start, end int64, matchers []*labels.Matcher) (string, []any, error) {
|
||||
adjustedStart, table := timeSeriesTableFor(start, end)
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("fingerprint", "any(labels)")
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sb, adjustedStart, end, matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.GroupBy("fingerprint")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
// buildSamplesQuery renders the samples fetch for the series selected by the
|
||||
// series lookup. Small matched sets pass inlineFingerprints — sorted uint64
|
||||
// literals that engage the samples primary key; nil means the set exceeded
|
||||
// the inline limit, and the filter becomes a semi-join re-running the series
|
||||
// predicates against the shard-local series table (complete by fingerprint
|
||||
// co-locality, see localTimeSeriesTable; a GLOBAL broadcast of the matched
|
||||
// set would ship it to every shard instead). metricNames narrows the
|
||||
// primary-key scan; when the selector had no __name__ equality, the names
|
||||
// observed on the matched series are used. A non-nil lastPerStep groups to
|
||||
// one (the last) sample per step bucket.
|
||||
func buildSamplesQuery(start, end int64, metricNames []string, inlineFingerprints []uint64, matchers []*labels.Matcher, lastPerStep *lastSamplePerStep) (string, []any, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
if lastPerStep != nil {
|
||||
// Aliases must not shadow source columns: ClickHouse resolves aliases
|
||||
// in WHERE too, and "max(unix_milli) AS unix_milli" would put an
|
||||
// aggregate into the WHERE clause (error 184).
|
||||
sb.Select("fingerprint", "max(unix_milli) AS ts", "argMax(value, unix_milli) AS val", "argMax(flags, unix_milli) AS fl")
|
||||
} else {
|
||||
sb.Select("fingerprint", "unix_milli", "value", "flags")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", databaseName, distributedSamplesV4))
|
||||
|
||||
switch len(metricNames) {
|
||||
case 0:
|
||||
// No name constraint derivable; correct but unable to use the
|
||||
// metric_name primary-key prefix.
|
||||
case 1:
|
||||
sb.Where(sb.EQ("metric_name", metricNames[0]))
|
||||
default:
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
}
|
||||
// temporality precedes metric_name in the samples primary key; the
|
||||
// fingerprints already come from these temporalities, so this only helps
|
||||
// granule pruning.
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
if inlineFingerprints != nil {
|
||||
sb.Where("fingerprint " + inlineFingerprintFilter(inlineFingerprints))
|
||||
} else {
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
sub.Select("fingerprint")
|
||||
adjustedStart, table := timeSeriesTableFor(start, end)
|
||||
sub.From(fmt.Sprintf("%s.%s", databaseName, localTimeSeriesTable(table)))
|
||||
if err := applySeriesConditions(sub, adjustedStart, end, matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb.Where(sb.In("fingerprint", sub))
|
||||
}
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LTE("unix_milli", end))
|
||||
|
||||
if lastPerStep != nil {
|
||||
sb.GroupBy("fingerprint")
|
||||
if expr := lastPerStep.bucketExpr(); expr != "" {
|
||||
sb.GroupBy(expr)
|
||||
}
|
||||
sb.OrderBy("fingerprint", "ts")
|
||||
} else {
|
||||
sb.OrderBy("fingerprint", "unix_milli")
|
||||
}
|
||||
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return query, args, nil
|
||||
}
|
||||
|
||||
// applySeriesConditions adds the WHERE conditions of a series table scan for
|
||||
// the given matchers and window. __name__ matchers translate to the
|
||||
// metric_name column (all four matcher types — the v1 client silently
|
||||
// returned nothing for regex metric names); every other matcher translates
|
||||
// to a JSONExtractString condition on the labels column. An equality matcher
|
||||
// against "" matches series without the label, mirroring PromQL, because
|
||||
// JSONExtractString returns "" for missing keys. Regexes are anchored:
|
||||
// PromQL matchers match the whole value, while ClickHouse match() searches
|
||||
// for a partial match — without anchoring, =~"api" would also select
|
||||
// "x-api-y".
|
||||
func applySeriesConditions(sb *sqlbuilder.SelectBuilder, start, end int64, matchers []*labels.Matcher) error {
|
||||
for _, m := range matchers {
|
||||
if m.Name != metricNameLabel {
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case labels.MatchEqual:
|
||||
sb.Where(sb.EQ("metric_name", m.Value))
|
||||
case labels.MatchNotEqual:
|
||||
sb.Where(sb.NE("metric_name", m.Value))
|
||||
case labels.MatchRegexp:
|
||||
sb.Where(fmt.Sprintf("match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
case labels.MatchNotRegexp:
|
||||
sb.Where(fmt.Sprintf("NOT match(metric_name, %s)", sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported matcher type %q for __name__", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
sb.Where(fmt.Sprintf("__normalized = %v", !constants.IsDotMetricsEnabled))
|
||||
sb.Where(sb.GTE("unix_milli", start), sb.LT("unix_milli", end))
|
||||
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel {
|
||||
continue
|
||||
}
|
||||
switch m.Type {
|
||||
case labels.MatchEqual:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) = %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case labels.MatchNotEqual:
|
||||
sb.Where(fmt.Sprintf("JSONExtractString(labels, %s) != %s", sb.Var(m.Name), sb.Var(m.Value)))
|
||||
case labels.MatchRegexp:
|
||||
sb.Where(fmt.Sprintf("match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
case labels.MatchNotRegexp:
|
||||
sb.Where(fmt.Sprintf("NOT match(JSONExtractString(labels, %s), %s)", sb.Var(m.Name), sb.Var(anchorRegex(m.Value))))
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported matcher type %q", m.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// anchorRegex turns a PromQL regex into its fully-anchored form (see
|
||||
// applySeriesConditions).
|
||||
func anchorRegex(v string) string {
|
||||
return "^(?:" + v + ")$"
|
||||
}
|
||||
|
||||
// inlineFingerprintFilter renders "IN (fp1, fp2, ...)" with literal uint64s.
|
||||
func inlineFingerprintFilter(fingerprints []uint64) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(fingerprints)*21 + 8)
|
||||
b.WriteString("IN (")
|
||||
for i, fp := range fingerprints {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(strconv.FormatUint(fp, 10))
|
||||
}
|
||||
b.WriteString(")")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// lastSamplePerStep reduces an instant-selector fetch to the last sample of
|
||||
// each step bucket. Buckets are anchored at the selector's first evaluation
|
||||
// timestamp so that every bucket boundary coincides with an evaluation
|
||||
// timestamp: bucket 0 is (start, firstEval] (the initial lookback window)
|
||||
// and bucket i is (firstEval+(i-1)·step, firstEval+i·step]. Keeping only the
|
||||
// last sample per bucket is lossless: the engine resolves each evaluation
|
||||
// timestamp t to the latest sample in (t-lookback, t], and a non-final
|
||||
// sample of a bucket can never be that latest sample for any t on the
|
||||
// evaluation grid. Real timestamps are preserved, so the engine's own
|
||||
// lookback and staleness handling remain exact.
|
||||
type lastSamplePerStep struct {
|
||||
firstEvalMs int64
|
||||
stepMs int64
|
||||
}
|
||||
|
||||
func (t *lastSamplePerStep) bucketExpr() string {
|
||||
if t.stepMs <= 0 {
|
||||
// Instant query: a single evaluation at firstEval; one bucket.
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"if(unix_milli <= %d, 0, intDiv(unix_milli - %d - 1, %d) + 1)",
|
||||
t.firstEvalMs, t.firstEvalMs, t.stepMs,
|
||||
)
|
||||
}
|
||||
148
pkg/prometheus/clickhouseprometheusv2/sql_test.go
Normal file
148
pkg/prometheus/clickhouseprometheusv2/sql_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mustMatcher(t *testing.T, mt labels.MatchType, name, value string) *labels.Matcher {
|
||||
t.Helper()
|
||||
m, err := labels.NewMatcher(mt, name, value)
|
||||
require.NoError(t, err)
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTimeSeriesTableFor(t *testing.T) {
|
||||
base := time.Date(2026, 7, 10, 3, 27, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
span time.Duration
|
||||
wantTable string
|
||||
roundTo time.Duration
|
||||
}{
|
||||
{"under 6h uses hourly table", 2 * time.Hour, distributedTimeSeriesV4, time.Hour},
|
||||
{"under 1d uses 6h table", 12 * time.Hour, distributedTimeSeriesV46hrs, 6 * time.Hour},
|
||||
{"under 1w uses 1d table", 3 * 24 * time.Hour, distributedTimeSeriesV41day, 24 * time.Hour},
|
||||
{"over 1w uses 1w table", 10 * 24 * time.Hour, distributedTimeSeriesV41week, 7 * 24 * time.Hour},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, table := timeSeriesTableFor(base, base+tt.span.Milliseconds())
|
||||
assert.Equal(t, tt.wantTable, table)
|
||||
assert.Zero(t, start%tt.roundTo.Milliseconds())
|
||||
assert.LessOrEqual(t, start, base)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSeriesQuery(t *testing.T) {
|
||||
start := int64(1_700_000_000_000)
|
||||
end := start + time.Hour.Milliseconds()
|
||||
// The series table window rounds down to the table's bucket boundary.
|
||||
adjustedStart := start - (start % time.Hour.Milliseconds())
|
||||
|
||||
t.Run("equality name and label matchers", func(t *testing.T) {
|
||||
query, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total"),
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"SELECT fingerprint, any(labels) FROM signoz_metrics.distributed_time_series_v4 WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified'] AND __normalized = false AND unix_milli >= ? AND unix_milli < ? AND JSONExtractString(labels, ?) = ? GROUP BY fingerprint",
|
||||
query,
|
||||
)
|
||||
assert.Equal(t, []any{"http_requests_total", adjustedStart, end, "job", "api"}, args)
|
||||
})
|
||||
|
||||
t.Run("regex matchers are anchored", func(t *testing.T) {
|
||||
_, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "up"),
|
||||
mustMatcher(t, labels.MatchRegexp, "instance", "prod.*"),
|
||||
mustMatcher(t, labels.MatchNotRegexp, "env", "dev|test"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{"up", adjustedStart, end, "instance", "^(?:prod.*)$", "env", "^(?:dev|test)$"}, args)
|
||||
})
|
||||
|
||||
t.Run("regex name matcher uses metric_name column", func(t *testing.T) {
|
||||
query, args, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchRegexp, "__name__", "node_cpu.*|node_memory.*"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "match(metric_name, ?)")
|
||||
assert.NotContains(t, query, "JSONExtractString")
|
||||
assert.Equal(t, []any{"^(?:node_cpu.*|node_memory.*)$", adjustedStart, end}, args)
|
||||
})
|
||||
|
||||
t.Run("no name matcher omits metric_name condition", func(t *testing.T) {
|
||||
query, _, err := buildSeriesQuery(start, end, []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, query, "metric_name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSamplesQuery(t *testing.T) {
|
||||
start := int64(1_700_000_000_000)
|
||||
end := start + time.Hour.Milliseconds()
|
||||
adjustedStart := start - (start % time.Hour.Milliseconds())
|
||||
matchers := []*labels.Matcher{
|
||||
mustMatcher(t, labels.MatchEqual, "__name__", "up"),
|
||||
mustMatcher(t, labels.MatchEqual, "job", "api"),
|
||||
}
|
||||
|
||||
t.Run("raw with inline fingerprints", func(t *testing.T) {
|
||||
query, args, err := buildSamplesQuery(start, end, []string{"up"}, []uint64{7, 42}, matchers, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
"SELECT fingerprint, unix_milli, value, flags FROM signoz_metrics.distributed_samples_v4 WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified'] AND fingerprint IN (7, 42) AND unix_milli >= ? AND unix_milli <= ? ORDER BY fingerprint, unix_milli",
|
||||
query,
|
||||
)
|
||||
assert.Equal(t, []any{"up", start, end}, args)
|
||||
})
|
||||
|
||||
t.Run("last-sample-per-step groups by step bucket anchored at first eval", func(t *testing.T) {
|
||||
lastPerStep := &lastSamplePerStep{firstEvalMs: start + 299_999, stepMs: 60_000}
|
||||
query, _, err := buildSamplesQuery(start, end, []string{"up"}, []uint64{7}, matchers, lastPerStep)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "argMax(value, unix_milli) AS val")
|
||||
assert.Contains(t, query, "argMax(flags, unix_milli) AS fl")
|
||||
assert.Contains(t, query, "GROUP BY fingerprint, if(unix_milli <= 1700000299999, 0, intDiv(unix_milli - 1700000299999 - 1, 60000) + 1)")
|
||||
assert.Contains(t, query, "ORDER BY fingerprint, ts")
|
||||
// Aliases must not shadow the source columns referenced in WHERE.
|
||||
assert.NotContains(t, query, "AS unix_milli")
|
||||
assert.NotContains(t, query, "AS value")
|
||||
assert.NotContains(t, query, "AS flags")
|
||||
})
|
||||
|
||||
t.Run("instant query keeps one bucket", func(t *testing.T) {
|
||||
lastPerStep := &lastSamplePerStep{firstEvalMs: end, stepMs: 0}
|
||||
query, _, err := buildSamplesQuery(start, end, []string{"up"}, []uint64{7}, matchers, lastPerStep)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "GROUP BY fingerprint ORDER BY fingerprint, ts")
|
||||
assert.NotContains(t, query, "intDiv")
|
||||
})
|
||||
|
||||
t.Run("over-limit set becomes a shard-local semi-join", func(t *testing.T) {
|
||||
query, args, err := buildSamplesQuery(start, end, []string{"up"}, nil, matchers, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "fingerprint IN (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE ")
|
||||
assert.NotContains(t, query, "GLOBAL IN")
|
||||
// Args follow placeholder order: samples metric name, the semi-join's
|
||||
// series predicates, then the samples window bounds.
|
||||
assert.Equal(t, []any{"up", "up", adjustedStart, end, "job", "api", start, end}, args)
|
||||
})
|
||||
|
||||
t.Run("multiple metric names from regex selector", func(t *testing.T) {
|
||||
query, args, err := buildSamplesQuery(start, end, []string{"node_cpu", "node_memory"}, []uint64{7}, matchers, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, query, "metric_name IN (?, ?)")
|
||||
assert.Equal(t, []any{"node_cpu", "node_memory", start, end}, args)
|
||||
})
|
||||
}
|
||||
65
pkg/prometheus/clickhouseprometheusv2/tables.go
Normal file
65
pkg/prometheus/clickhouseprometheusv2/tables.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// metricNameLabel is the reserved PromQL label holding the metric name.
|
||||
metricNameLabel string = "__name__"
|
||||
|
||||
databaseName string = "signoz_metrics"
|
||||
distributedTimeSeriesV4 string = "distributed_time_series_v4"
|
||||
distributedTimeSeriesV46hrs string = "distributed_time_series_v4_6hrs"
|
||||
distributedTimeSeriesV41day string = "distributed_time_series_v4_1day"
|
||||
distributedTimeSeriesV41week string = "distributed_time_series_v4_1week"
|
||||
distributedSamplesV4 string = "distributed_samples_v4"
|
||||
|
||||
localTimeSeriesV4 string = "time_series_v4"
|
||||
localTimeSeriesV46hrs string = "time_series_v4_6hrs"
|
||||
localTimeSeriesV41day string = "time_series_v4_1day"
|
||||
localTimeSeriesV41week string = "time_series_v4_1week"
|
||||
)
|
||||
|
||||
// localTimeSeriesTable maps a distributed time series table to its shard-local
|
||||
// table. Samples and time series shard on the same key
|
||||
// (cityHash64(env, temporality, metric_name, fingerprint)), so a query whose
|
||||
// top-level FROM is the distributed samples table can join or semi-join the
|
||||
// local time series table inside each shard: the shard rewrite runs the
|
||||
// subquery against the shard's own series rows, which are exactly the series
|
||||
// of the shard's samples. No broadcast, no initiator-side join.
|
||||
func localTimeSeriesTable(distributed string) string {
|
||||
switch distributed {
|
||||
case distributedTimeSeriesV46hrs:
|
||||
return localTimeSeriesV46hrs
|
||||
case distributedTimeSeriesV41day:
|
||||
return localTimeSeriesV41day
|
||||
case distributedTimeSeriesV41week:
|
||||
return localTimeSeriesV41week
|
||||
default:
|
||||
return localTimeSeriesV4
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
oneHourInMilliseconds = time.Hour.Milliseconds()
|
||||
sixHoursInMilliseconds = time.Hour.Milliseconds() * 6
|
||||
oneDayInMilliseconds = time.Hour.Milliseconds() * 24
|
||||
oneWeekInMilliseconds = time.Hour.Milliseconds() * 24 * 7
|
||||
)
|
||||
|
||||
// timeSeriesTableFor returns the adjusted start and the time series table for
|
||||
// the window. Time series tables hold one row per (fingerprint, bucket), with
|
||||
// bucket granularities of 1h, 6h, 1d and 1w; the start is rounded down to the
|
||||
// bucket boundary so a window beginning mid-bucket still matches the bucket's
|
||||
// row.
|
||||
func timeSeriesTableFor(start, end int64) (int64, string) {
|
||||
switch {
|
||||
case end-start < sixHoursInMilliseconds:
|
||||
return start - (start % oneHourInMilliseconds), distributedTimeSeriesV4
|
||||
case end-start < oneDayInMilliseconds:
|
||||
return start - (start % sixHoursInMilliseconds), distributedTimeSeriesV46hrs
|
||||
case end-start < oneWeekInMilliseconds:
|
||||
return start - (start % oneDayInMilliseconds), distributedTimeSeriesV41day
|
||||
default:
|
||||
return start - (start % oneWeekInMilliseconds), distributedTimeSeriesV41week
|
||||
}
|
||||
}
|
||||
485
pkg/prometheus/clickhouseprometheusv2/transpiler.go
Normal file
485
pkg/prometheus/clickhouseprometheusv2/transpiler.go
Normal file
@@ -0,0 +1,485 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
// The compiler turns PromQL subtrees into single ClickHouse queries built on
|
||||
// the timeSeries*ToGrid aggregate functions (CH >= 25.6), whose semantics
|
||||
// were verified against this repo's vendored engine: exact extrapolatedRate
|
||||
// behavior including counter resets, the counter zero-point clamp, the
|
||||
// 1.1x-average extrapolation threshold, left-open windows, the >= 2 samples
|
||||
// rule, stale-marker shadowing, and millisecond grid starts. Sample rows
|
||||
// never leave ClickHouse: one row per output series comes back, holding the
|
||||
// whole grid as an array.
|
||||
//
|
||||
// Scope (the allowlist): an optional sum/min/max/avg/count by/without
|
||||
// aggregation over a core unit — a rate/increase/delta/irate/idelta range
|
||||
// selection, an instant vector selection, or an avg/min/max/sum/count/last
|
||||
// _over_time window — plus number-literal arithmetic/comparisons and unary
|
||||
// minus on top. Units inside fixed-resolution subqueries evaluate on the
|
||||
// subquery's own grid. Everything else either falls back to the engine over
|
||||
// this package's querier, or — when a transpilable subtree sits under a
|
||||
// non-transpilable node — runs hybrid: the subtree's grids are computed in
|
||||
// ClickHouse and substituted into the engine as synthetic series (see
|
||||
// compiler_exec.go). See doc.go for the fallback list and the reasons behind
|
||||
// each entry.
|
||||
|
||||
// rangeFn is a transpilable range-vector function.
|
||||
type rangeFn string
|
||||
|
||||
const (
|
||||
fnRate rangeFn = "rate"
|
||||
fnIncrease rangeFn = "increase"
|
||||
fnDelta rangeFn = "delta"
|
||||
fnIRate rangeFn = "irate"
|
||||
fnIDelta rangeFn = "idelta"
|
||||
)
|
||||
|
||||
var gridFunction = map[rangeFn]string{
|
||||
fnRate: "timeSeriesRateToGrid",
|
||||
fnIncrease: "timeSeriesRateToGrid", // increase == rate * range seconds, exactly (same factor algebra)
|
||||
fnDelta: "timeSeriesDeltaToGrid",
|
||||
fnIRate: "timeSeriesInstantRateToGrid",
|
||||
fnIDelta: "timeSeriesInstantDeltaToGrid",
|
||||
}
|
||||
|
||||
// scalarOp is one number-literal arithmetic or comparison applied to a
|
||||
// compiled vector, evaluated in Go during assembly with the same float64
|
||||
// operations the engine uses.
|
||||
type scalarOp struct {
|
||||
op parser.ItemType
|
||||
scalar float64
|
||||
scalarOnLeft bool
|
||||
returnBool bool
|
||||
}
|
||||
|
||||
// isComparison reports whether the op is a filtering/bool comparison, which
|
||||
// preserves the metric name (arithmetic drops it).
|
||||
func (o scalarOp) isComparison() bool {
|
||||
return o.op.IsComparisonOperator()
|
||||
}
|
||||
|
||||
// unitKind is the selector shape at the bottom of a core unit.
|
||||
type unitKind int
|
||||
|
||||
const (
|
||||
// unitRange: rate/increase/delta/irate/idelta over a matrix selector.
|
||||
unitRange unitKind = iota
|
||||
// unitInstant: a plain vector selector resolved per grid point with
|
||||
// lookback and stale-marker shadowing.
|
||||
unitInstant
|
||||
// unitOverTime: avg/min/max/sum/count/last_over_time over a matrix
|
||||
// selector (aggregation over the window's samples, stale rows excluded).
|
||||
unitOverTime
|
||||
)
|
||||
|
||||
// coreUnit is one transpilable subtree: selector [-> range function] ->
|
||||
// optional aggregation -> scalar op pipeline.
|
||||
type coreUnit struct {
|
||||
kind unitKind
|
||||
matchers []*labels.Matcher
|
||||
offsetMs int64
|
||||
fn rangeFn // unitRange
|
||||
overFn string // unitOverTime: avg|min|max|sum|count|last
|
||||
rangeMs int64 // unitRange/unitOverTime window
|
||||
|
||||
hasAgg bool
|
||||
aggOp parser.ItemType // SUM MIN MAX AVG COUNT
|
||||
by bool
|
||||
grouping []string
|
||||
|
||||
ops []scalarOp
|
||||
}
|
||||
|
||||
// keepsName reports whether the unit's output series keep their real
|
||||
// __name__: bare/comparison-filtered instant selectors and last_over_time do
|
||||
// (it returns the raw sample, name included); range functions, the other
|
||||
// *_over_time functions, aggregations, arithmetic and bool comparisons all
|
||||
// drop it — a bool comparison returns 0/1, not the sample, so the engine
|
||||
// drops the name there too. Units that keep the name cannot be substituted
|
||||
// as synthetic series in hybrid plans — the synthetic name would replace
|
||||
// the real one — but transpile fine as full plans, where assembly emits the
|
||||
// real names.
|
||||
func (u *coreUnit) keepsName() bool {
|
||||
nameKeepingSelector := u.kind == unitInstant || (u.kind == unitOverTime && u.overFn == "last")
|
||||
if !nameKeepingSelector || u.hasAgg {
|
||||
return false
|
||||
}
|
||||
for _, op := range u.ops {
|
||||
if !op.isComparison() || op.returnBool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// gridContext is the evaluation grid a unit computes on. The query grid for
|
||||
// top-level units; for units inside subqueries, the subquery's own grid:
|
||||
// epoch-aligned multiples of its resolution covering the subquery window,
|
||||
// exactly as the engine derives it (engine.go, *parser.SubqueryExpr case).
|
||||
type gridContext struct {
|
||||
startMs int64
|
||||
endMs int64
|
||||
stepMs int64
|
||||
}
|
||||
|
||||
// subqueryGrid derives the inner grid for a subquery evaluated on outer:
|
||||
// interval S, end = outer end − offset, start = first multiple of S strictly
|
||||
// greater than outer start − offset − range.
|
||||
func subqueryGrid(outer gridContext, rangeMs, stepMs, offsetMs int64) gridContext {
|
||||
lower := outer.startMs - offsetMs - rangeMs
|
||||
start := stepMs * (lower / stepMs)
|
||||
if start <= lower {
|
||||
start += stepMs
|
||||
}
|
||||
return gridContext{startMs: start, endMs: outer.endMs - offsetMs, stepMs: stepMs}
|
||||
}
|
||||
|
||||
// transpiledUnit is a coreUnit scheduled for execution, named for hybrid
|
||||
// substitution, carrying the grid it evaluates on.
|
||||
type transpiledUnit struct {
|
||||
core coreUnit
|
||||
name string // __signoz_transpiled_<n>__
|
||||
grid gridContext
|
||||
}
|
||||
|
||||
// transpilePlan is the outcome of classifying a query.
|
||||
type transpilePlan struct {
|
||||
units []*transpiledUnit
|
||||
grid gridContext // the query's top-level grid
|
||||
// full is set when the entire query is units[0]; otherwise rewritten
|
||||
// holds the query with each unit replaced by a synthetic selector, to be
|
||||
// evaluated by the engine over a hybrid storage.
|
||||
full bool
|
||||
rewritten string
|
||||
}
|
||||
|
||||
const syntheticNamePrefix = "__signoz_transpiled_"
|
||||
|
||||
func syntheticName(i int) string {
|
||||
return fmt.Sprintf("%s%d__", syntheticNamePrefix, i)
|
||||
}
|
||||
|
||||
// classifyCore matches a subtree against the transpilable core shape.
|
||||
// stepMs gates second-granularity: the grid functions take whole-second step
|
||||
// and window parameters (grid *starts* are millisecond-precise).
|
||||
func classifyCore(node parser.Expr, stepMs int64) (*coreUnit, bool) {
|
||||
unit := &coreUnit{}
|
||||
|
||||
expr := node
|
||||
// Peel scalar ops and parens off the top, outermost first; ops apply in
|
||||
// evaluation order, so prepend while peeling.
|
||||
for {
|
||||
switch n := expr.(type) {
|
||||
case *parser.ParenExpr:
|
||||
expr = n.Expr
|
||||
continue
|
||||
case *parser.UnaryExpr:
|
||||
if n.Op != parser.SUB {
|
||||
expr = n.Expr // unary '+' is a no-op
|
||||
continue
|
||||
}
|
||||
// -x == -1 * x for every float64 (incl. NaN and signed zero).
|
||||
unit.ops = append([]scalarOp{{op: parser.MUL, scalar: -1}}, unit.ops...)
|
||||
expr = n.Expr
|
||||
continue
|
||||
case *parser.StepInvariantExpr:
|
||||
// @-pinned expressions evaluate on a different grid.
|
||||
return nil, false
|
||||
case *parser.BinaryExpr:
|
||||
lit, litOnLeft, ok := numberLiteralSide(n)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if !n.Op.IsOperator() && !n.Op.IsComparisonOperator() {
|
||||
return nil, false
|
||||
}
|
||||
if n.Op == parser.ATAN2 {
|
||||
// atan2 is arithmetic in PromQL but rarely used; keep the
|
||||
// allowlist tight.
|
||||
return nil, false
|
||||
}
|
||||
returnBool := n.ReturnBool
|
||||
unit.ops = append([]scalarOp{{op: n.Op, scalar: lit, scalarOnLeft: litOnLeft, returnBool: returnBool}}, unit.ops...)
|
||||
if litOnLeft {
|
||||
expr = n.RHS
|
||||
} else {
|
||||
expr = n.LHS
|
||||
}
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Optional aggregation.
|
||||
if agg, ok := expr.(*parser.AggregateExpr); ok {
|
||||
switch agg.Op {
|
||||
case parser.SUM, parser.MIN, parser.MAX, parser.AVG, parser.COUNT:
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
for _, g := range agg.Grouping {
|
||||
if g == metricNameLabel {
|
||||
// by(__name__)/without(__name__) over synthetic or compiled
|
||||
// output needs name bookkeeping the compiler doesn't do.
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
unit.hasAgg = true
|
||||
unit.aggOp = agg.Op
|
||||
unit.by = !agg.Without
|
||||
unit.grouping = agg.Grouping
|
||||
expr = agg.Expr
|
||||
for {
|
||||
if p, ok := expr.(*parser.ParenExpr); ok {
|
||||
expr = p.Expr
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The grid functions take whole-second steps; stepMs == 0 is an instant
|
||||
// query (single-point grid).
|
||||
if stepMs < 0 || stepMs%1000 != 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Bare instant selector: resolved per grid point with lookback and
|
||||
// stale-marker shadowing (see compiler_sql.go).
|
||||
if vs, ok := expr.(*parser.VectorSelector); ok {
|
||||
if vs.Timestamp != nil || vs.StartOrEnd != 0 || vs.Anchored || vs.Smoothed {
|
||||
return nil, false
|
||||
}
|
||||
offsetMs := vs.OriginalOffset.Milliseconds()
|
||||
if offsetMs < 0 {
|
||||
return nil, false
|
||||
}
|
||||
unit.kind = unitInstant
|
||||
unit.offsetMs = offsetMs
|
||||
unit.matchers = vs.LabelMatchers
|
||||
return unit, true
|
||||
}
|
||||
|
||||
// Range or *_over_time function over a plain matrix selector.
|
||||
call, ok := expr.(*parser.Call)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var fn rangeFn
|
||||
var overFn string
|
||||
switch call.Func.Name {
|
||||
case "rate":
|
||||
fn = fnRate
|
||||
case "increase":
|
||||
fn = fnIncrease
|
||||
case "delta":
|
||||
fn = fnDelta
|
||||
case "irate":
|
||||
fn = fnIRate
|
||||
case "idelta":
|
||||
fn = fnIDelta
|
||||
case "avg_over_time", "min_over_time", "max_over_time", "sum_over_time", "count_over_time", "last_over_time":
|
||||
overFn = strings.TrimSuffix(call.Func.Name, "_over_time")
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
if len(call.Args) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
ms, ok := call.Args[0].(*parser.MatrixSelector)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
vs, ok := ms.VectorSelector.(*parser.VectorSelector)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if vs.Timestamp != nil || vs.StartOrEnd != 0 || vs.Anchored || vs.Smoothed {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
rangeMs := ms.Range.Milliseconds()
|
||||
offsetMs := vs.OriginalOffset.Milliseconds()
|
||||
if rangeMs <= 0 || rangeMs%1000 != 0 || offsetMs < 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if overFn != "" {
|
||||
unit.kind = unitOverTime
|
||||
unit.overFn = overFn
|
||||
} else {
|
||||
unit.kind = unitRange
|
||||
unit.fn = fn
|
||||
}
|
||||
unit.rangeMs = rangeMs
|
||||
unit.offsetMs = offsetMs
|
||||
unit.matchers = vs.LabelMatchers
|
||||
return unit, true
|
||||
}
|
||||
|
||||
// numberLiteralSide returns the number literal on one side of a binary
|
||||
// expression (peeling parens and unary minus), and which side it is on.
|
||||
func numberLiteralSide(b *parser.BinaryExpr) (float64, bool, bool) {
|
||||
if v, ok := literalValue(b.LHS); ok {
|
||||
return v, true, true
|
||||
}
|
||||
if v, ok := literalValue(b.RHS); ok {
|
||||
return v, false, true
|
||||
}
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
func literalValue(e parser.Expr) (float64, bool) {
|
||||
neg := false
|
||||
for {
|
||||
switch n := e.(type) {
|
||||
case *parser.ParenExpr:
|
||||
e = n.Expr
|
||||
continue
|
||||
case *parser.StepInvariantExpr:
|
||||
e = n.Expr
|
||||
continue
|
||||
case *parser.UnaryExpr:
|
||||
if n.Op == parser.SUB {
|
||||
neg = !neg
|
||||
}
|
||||
e = n.Expr
|
||||
continue
|
||||
case *parser.NumberLiteral:
|
||||
if neg {
|
||||
return -n.Val, true
|
||||
}
|
||||
return n.Val, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// classify builds the compile plan for a query: full when the root is a core
|
||||
// unit, hybrid when core units sit strictly below the root (including inside
|
||||
// fixed-resolution subqueries, computed on the subquery grid), none
|
||||
// otherwise.
|
||||
func classify(root parser.Expr, grid gridContext) (*transpilePlan, bool) {
|
||||
if unit, ok := classifyCore(root, grid.stepMs); ok {
|
||||
return &transpilePlan{
|
||||
units: []*transpiledUnit{{core: *unit, name: syntheticName(0), grid: grid}},
|
||||
grid: grid,
|
||||
full: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
plan := &transpilePlan{grid: grid}
|
||||
rewritten := rewrite(root, grid, plan, false)
|
||||
if len(plan.units) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
plan.rewritten = rewritten.String()
|
||||
return plan, true
|
||||
}
|
||||
|
||||
// rewrite walks top-down replacing maximal transpilable subtrees with synthetic
|
||||
// vector selectors. nameSensitive marks scopes where an ancestor's semantics
|
||||
// depend on __name__ (grouping or vector matching on it): synthetic series
|
||||
// carry a synthetic __name__, so substitution there would change results.
|
||||
// Fixed-resolution subqueries recurse with the subquery's own grid; scopes
|
||||
// whose evaluation grid is unknowable (@-pinned, default-resolution
|
||||
// subqueries) are not entered.
|
||||
func rewrite(node parser.Expr, grid gridContext, plan *transpilePlan, nameSensitive bool) parser.Expr {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !nameSensitive {
|
||||
// Units whose output keeps the real __name__ (bare instant selectors)
|
||||
// cannot be substituted: the synthetic name would replace it in the
|
||||
// engine's output. They still compile as full plans.
|
||||
if unit, ok := classifyCore(node, grid.stepMs); ok && !unit.keepsName() {
|
||||
cu := &transpiledUnit{core: *unit, name: syntheticName(len(plan.units)), grid: grid}
|
||||
plan.units = append(plan.units, cu)
|
||||
return &parser.VectorSelector{
|
||||
Name: cu.name,
|
||||
LabelMatchers: []*labels.Matcher{
|
||||
labels.MustNewMatcher(labels.MatchEqual, metricNameLabel, cu.name),
|
||||
},
|
||||
PosRange: node.PositionRange(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *parser.ParenExpr:
|
||||
n.Expr = rewrite(n.Expr, grid, plan, nameSensitive)
|
||||
case *parser.UnaryExpr:
|
||||
n.Expr = rewrite(n.Expr, grid, plan, nameSensitive)
|
||||
case *parser.AggregateExpr:
|
||||
sensitive := nameSensitive || groupingUsesName(n.Grouping)
|
||||
n.Expr = rewrite(n.Expr, grid, plan, sensitive)
|
||||
// n.Param is a scalar/string; nothing transpilable inside for our core.
|
||||
case *parser.Call:
|
||||
for i, arg := range n.Args {
|
||||
n.Args[i] = rewrite(arg, grid, plan, nameSensitive)
|
||||
}
|
||||
case *parser.BinaryExpr:
|
||||
sensitive := nameSensitive || vectorMatchingUsesName(n.VectorMatching)
|
||||
n.LHS = rewrite(n.LHS, grid, plan, sensitive)
|
||||
n.RHS = rewrite(n.RHS, grid, plan, sensitive)
|
||||
case *parser.SubqueryExpr:
|
||||
// The alert-smoothing idiom fn_over_time((expr)[R:S]) dominates real
|
||||
// rule fleets; inner units evaluate on the subquery grid, and the
|
||||
// engine does the smoothing over the synthetic series. Requires an
|
||||
// explicit whole-second resolution (S == 0 needs the engine's
|
||||
// default-interval function) and no @ pinning.
|
||||
stepMs := n.Step.Milliseconds()
|
||||
rangeMs := n.Range.Milliseconds()
|
||||
offsetMs := n.OriginalOffset.Milliseconds()
|
||||
if n.Timestamp == nil && n.StartOrEnd == 0 &&
|
||||
stepMs > 0 && stepMs%1000 == 0 && rangeMs%1000 == 0 && offsetMs >= 0 {
|
||||
inner := subqueryGrid(grid, rangeMs, stepMs, offsetMs)
|
||||
n.Expr = rewrite(n.Expr, inner, plan, nameSensitive)
|
||||
}
|
||||
case *parser.StepInvariantExpr, *parser.MatrixSelector,
|
||||
*parser.VectorSelector, *parser.NumberLiteral, *parser.StringLiteral:
|
||||
// Leaves, or scopes substitution must not enter.
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func groupingUsesName(grouping []string) bool {
|
||||
for _, g := range grouping {
|
||||
if g == metricNameLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func vectorMatchingUsesName(vm *parser.VectorMatching) bool {
|
||||
if vm == nil {
|
||||
return false
|
||||
}
|
||||
for _, l := range append(append([]string{}, vm.MatchingLabels...), vm.Include...) {
|
||||
if l == metricNameLabel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Default (all-labels) matching ignores __name__, and by()/ignoring()
|
||||
// lists were checked above.
|
||||
return false
|
||||
}
|
||||
|
||||
// isSyntheticSelector reports whether matchers target a compiled unit.
|
||||
func isSyntheticSelector(matchers []*labels.Matcher) (string, bool) {
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && strings.HasPrefix(m.Value, syntheticNamePrefix) {
|
||||
return m.Value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
150
pkg/prometheus/clickhouseprometheusv2/transpiler_corpus_test.go
Normal file
150
pkg/prometheus/clickhouseprometheusv2/transpiler_corpus_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestClassifyCorpus measures real-workload compiler coverage: it classifies
|
||||
// every query of a JSON-lines corpus (one JSON-encoded PromQL string per
|
||||
// line) with the live classifier and reports full / hybrid / fallback
|
||||
// shares. Skipped unless PROMQL_CORPUS points to one or more files
|
||||
// (comma-separated). Dashboard template variables are substituted with
|
||||
// placeholder values before parsing, mirroring the production render step.
|
||||
//
|
||||
// PROMQL_CORPUS=corpus-a.jsonl,corpus-b.jsonl go test -run TestClassifyCorpus -v
|
||||
func TestClassifyCorpus(t *testing.T) {
|
||||
corpus := os.Getenv("PROMQL_CORPUS")
|
||||
if corpus == "" {
|
||||
t.Skip("PROMQL_CORPUS not set")
|
||||
}
|
||||
|
||||
varRe := regexp.MustCompile(`\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$[\w.]+`)
|
||||
promParser := parser.NewParser(parser.Options{})
|
||||
|
||||
for _, path := range strings.Split(corpus, ",") {
|
||||
f, err := os.Open(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
var full, hybrid, fallbackInstant, fallbackOther, parseErrs int
|
||||
fallbackReasons := map[string]int{}
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
var query string
|
||||
require.NoError(t, json.Unmarshal(scanner.Bytes(), &query))
|
||||
query = varRe.ReplaceAllString(query, "placeholder")
|
||||
|
||||
expr, err := promParser.ParseExpr(query)
|
||||
if err != nil {
|
||||
parseErrs++
|
||||
continue
|
||||
}
|
||||
|
||||
plan, ok := classify(expr, gridContext{startMs: 1_700_000_000_000, endMs: 1_700_007_200_000, stepMs: 60_000})
|
||||
switch {
|
||||
case ok && plan.full:
|
||||
full++
|
||||
case ok:
|
||||
hybrid++
|
||||
default:
|
||||
reason := fallbackShape(expr)
|
||||
fallbackReasons[reason]++
|
||||
if reason == "instant-selector shape (last-sample-per-step engine path)" {
|
||||
fallbackInstant++
|
||||
} else {
|
||||
fallbackOther++
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
_ = f.Close()
|
||||
|
||||
total := full + hybrid + fallbackInstant + fallbackOther
|
||||
if total == 0 {
|
||||
t.Logf("%s: no parseable queries (%d parse errors)", path, parseErrs)
|
||||
continue
|
||||
}
|
||||
t.Logf("%s: %d queries — full=%d (%.0f%%) hybrid=%d (%.0f%%) fallback=%d (%.0f%%; instant-shape=%d) parse_errors=%d",
|
||||
path, total,
|
||||
full, 100*float64(full)/float64(total),
|
||||
hybrid, 100*float64(hybrid)/float64(total),
|
||||
fallbackInstant+fallbackOther, 100*float64(fallbackInstant+fallbackOther)/float64(total),
|
||||
fallbackInstant, parseErrs)
|
||||
|
||||
reasons := make([]string, 0, len(fallbackReasons))
|
||||
for r := range fallbackReasons {
|
||||
reasons = append(reasons, r)
|
||||
}
|
||||
sort.Slice(reasons, func(i, j int) bool { return fallbackReasons[reasons[i]] > fallbackReasons[reasons[j]] })
|
||||
for _, r := range reasons {
|
||||
t.Logf(" fallback %4d %s", fallbackReasons[r], r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackShape buckets a non-transpilable query by why it stays on the engine
|
||||
// path, to separate "already served well" (instant selectors on the last-sample-per-step
|
||||
// path) from genuine compiler gaps.
|
||||
func fallbackShape(expr parser.Expr) string {
|
||||
var hasMatrix, hasSubquery, hasAt, overTime bool
|
||||
rangeFns := map[string]bool{"rate": true, "increase": true, "delta": true, "irate": true, "idelta": true}
|
||||
var unsupportedFns []string
|
||||
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
|
||||
switch n := node.(type) {
|
||||
case *parser.MatrixSelector:
|
||||
hasMatrix = true
|
||||
case *parser.SubqueryExpr:
|
||||
hasSubquery = true
|
||||
case *parser.VectorSelector:
|
||||
if n.Timestamp != nil || n.StartOrEnd != 0 {
|
||||
hasAt = true
|
||||
}
|
||||
case *parser.Call:
|
||||
if strings.HasSuffix(n.Func.Name, "_over_time") {
|
||||
overTime = true
|
||||
} else if !rangeFns[n.Func.Name] {
|
||||
unsupportedFns = append(unsupportedFns, n.Func.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
switch {
|
||||
case hasSubquery:
|
||||
return "subquery"
|
||||
case hasAt:
|
||||
return "@ modifier"
|
||||
case overTime:
|
||||
return "*_over_time range function"
|
||||
case !hasMatrix:
|
||||
return "instant-selector shape (last-sample-per-step engine path)"
|
||||
case len(unsupportedFns) > 0:
|
||||
return fmt.Sprintf("range shape with unsupported function(s): %s", strings.Join(dedupe(unsupportedFns), ",")) //nolint:makezero
|
||||
default:
|
||||
return "other range shape"
|
||||
}
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
463
pkg/prometheus/clickhouseprometheusv2/transpiler_exec.go
Normal file
463
pkg/prometheus/clickhouseprometheusv2/transpiler_exec.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
promValue "github.com/prometheus/prometheus/model/value"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// executor evaluates transpilable PromQL directly in ClickHouse, falling
|
||||
// back (ok=false) whenever the query shape or the step doesn't qualify. The
|
||||
// timeSeries*ToGrid functions it builds on are assumed available: the
|
||||
// supported ClickHouse floor is >= 25.6.
|
||||
type executor struct {
|
||||
client *client
|
||||
engine *prometheus.Engine
|
||||
parser prometheus.Parser
|
||||
}
|
||||
|
||||
// TryExecuteRange transpiles and runs the query in ClickHouse when its shape
|
||||
// is in the allowlist. ok=false means "not transpilable" and carries no
|
||||
// error; the caller runs the engine path.
|
||||
func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
|
||||
expr, err := e.parser.ParseExpr(qs)
|
||||
if err != nil {
|
||||
// Let the engine path produce the (enhanced) parse error.
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
plan, ok := classify(expr, queryGrid(start, end, step))
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// timeSeriesLastToGrid widens its window to max(window, step) — probed: a
|
||||
// sample aged (window, step] still fills the slot — while the rate/delta
|
||||
// family enforces the window strictly. The Last-style kinds therefore
|
||||
// transpile only when their window covers the step; otherwise the engine
|
||||
// path serves them exactly.
|
||||
for _, unit := range plan.units {
|
||||
lastStyle := unit.core.kind == unitInstant || (unit.core.kind == unitOverTime && unit.core.overFn == "last")
|
||||
if !lastStyle {
|
||||
continue
|
||||
}
|
||||
windowMs := unit.core.rangeMs
|
||||
if unit.core.kind == unitInstant {
|
||||
windowMs = e.client.lookbackMs
|
||||
}
|
||||
if windowMs < unit.grid.stepMs {
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate every unit concurrently on its own grid (the query grid, or a
|
||||
// subquery grid); each is one series lookup plus one grid query. The
|
||||
// units share one grid-cell budget: transpiled results never pass
|
||||
// through the engine's sample limiter, so without this a large
|
||||
// series-count x grid-width query would buffer unbounded arrays — the
|
||||
// OOM this provider exists to prevent.
|
||||
results := make([][]transpiledSeries, len(plan.units))
|
||||
var gridCells atomic.Int64
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
for i, unit := range plan.units {
|
||||
eg.Go(func() error {
|
||||
res, err := e.executeUnit(egCtx, &unit.core, unit.grid, &gridCells)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results[i] = res
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := eg.Wait(); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
if plan.full {
|
||||
g := plan.units[0].grid
|
||||
return toMatrix(results[0], g.startMs, g.stepMs), true, nil
|
||||
}
|
||||
|
||||
matrix, err := e.executeHybrid(ctx, plan, results)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return matrix, true, nil
|
||||
}
|
||||
|
||||
// queryGrid derives the top-level evaluation grid; step 0 is an instant
|
||||
// query: a single evaluation at end, whatever start was.
|
||||
func queryGrid(start, end time.Time, step time.Duration) gridContext {
|
||||
startMs, endMs, stepMs := start.UnixMilli(), end.UnixMilli(), step.Milliseconds()
|
||||
if stepMs == 0 {
|
||||
startMs = endMs
|
||||
}
|
||||
return gridContext{startMs: startMs, endMs: endMs, stepMs: stepMs}
|
||||
}
|
||||
|
||||
// transpiledSeries is one output series of a unit: projected labels and one
|
||||
// value pointer per grid point (nil = absent).
|
||||
type transpiledSeries struct {
|
||||
lset labels.Labels
|
||||
values []*float64
|
||||
}
|
||||
|
||||
// executeUnit runs one core unit on its grid: series lookup (budgets,
|
||||
// fingerprints, metric names), then the single grid query, then the
|
||||
// scalar-op pipeline.
|
||||
func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridContext, gridCells *atomic.Int64) ([]transpiledSeries, error) {
|
||||
startMs, endMs, stepMs := grid.startMs, grid.endMs, grid.stepMs
|
||||
windowMs := unit.rangeMs
|
||||
if unit.kind == unitInstant {
|
||||
windowMs = e.client.lookbackMs
|
||||
}
|
||||
dataStart := startMs - unit.offsetMs - windowMs
|
||||
dataEnd := endMs - unit.offsetMs
|
||||
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The result buffers one grid array per series; series count times grid
|
||||
// width is the transpiled equivalent of fetched samples, counted before
|
||||
// the arrays exist rather than after the memory is spent.
|
||||
gridLen := int64(1)
|
||||
if stepMs > 0 {
|
||||
gridLen = (endMs-startMs)/stepMs + 1
|
||||
}
|
||||
if maxSamples := e.client.cfg.MaxFetchedSamples; maxSamples > 0 && gridCells.Add(int64(len(lookup.fingerprints))*gridLen) > maxSamples {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"promql query would buffer more than %d output points; narrow the selector or time range, or raise prometheus::clickhousev2::max_fetched_samples",
|
||||
maxSamples,
|
||||
)
|
||||
}
|
||||
|
||||
query, args, err := buildUnitSQL(unit, lookup.metricNames, transpiledFingerprintFilter(lookup), dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := e.client.telemetryStore.ClickhouseDB().Query(e.client.withContext(ctx, "transpiledUnit"), query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Name-dropping units keep __name__ in the SQL group key so distinct
|
||||
// metrics never merge server-side; the name comes off here, and a
|
||||
// post-strip collision is the engine's duplicate-labelset error — v1
|
||||
// would have errored, so silently inventing a merged series would be a
|
||||
// divergence.
|
||||
stripName := !unit.hasAgg && !unit.keepsName()
|
||||
seen := make(map[uint64]string)
|
||||
|
||||
var out []transpiledSeries
|
||||
var gkey string
|
||||
var gridValues []*float64
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&gkey, &gridValues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lset, err := labelsFromGroupKey(gkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stripName {
|
||||
name := lset.Get(metricNameLabel)
|
||||
lset = labels.NewBuilder(lset).Del(metricNameLabel).Labels()
|
||||
if prev, ok := seen[lset.Hash()]; ok && prev != name {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "vector cannot contain metrics with the same labelset")
|
||||
}
|
||||
seen[lset.Hash()] = name
|
||||
}
|
||||
values := make([]*float64, len(gridValues))
|
||||
copy(values, gridValues)
|
||||
applyScalarOps(unit.ops, values)
|
||||
out = append(out, transpiledSeries{lset: lset, values: values})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].lset, out[j].lset) < 0 })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// transpiledFingerprintFilter returns the matched fingerprints as a sorted
|
||||
// slice when they fit the inline limit — literals engage the samples primary
|
||||
// key, and sorting keeps the statement deterministic for logging and tests.
|
||||
// Over the limit it returns nil: the unit query's INNER JOIN against the
|
||||
// local series subquery restricts to exactly the matched fingerprints
|
||||
// already, and a semi-join on the same predicates would only rescan the
|
||||
// series table.
|
||||
func transpiledFingerprintFilter(lookup *seriesLookup) []uint64 {
|
||||
if len(lookup.fingerprints) > inlineFingerprintsLimit {
|
||||
return nil
|
||||
}
|
||||
fingerprints := make([]uint64, 0, len(lookup.fingerprints))
|
||||
for fp := range lookup.fingerprints {
|
||||
fingerprints = append(fingerprints, fp)
|
||||
}
|
||||
sort.Slice(fingerprints, func(i, j int) bool { return fingerprints[i] < fingerprints[j] })
|
||||
return fingerprints
|
||||
}
|
||||
|
||||
// labelsFromGroupKey parses the toJSONString'd sorted [key, value] pairs.
|
||||
func labelsFromGroupKey(gkey string) (labels.Labels, error) {
|
||||
var pairs [][]string
|
||||
if err := json.Unmarshal([]byte(gkey), &pairs); err != nil {
|
||||
return labels.EmptyLabels(), errors.WrapInternalf(err, errors.CodeInternal, "malformed compiled group key %q", gkey)
|
||||
}
|
||||
builder := labels.NewScratchBuilder(len(pairs))
|
||||
for _, p := range pairs {
|
||||
if len(p) != 2 {
|
||||
return labels.EmptyLabels(), errors.NewInternalf(errors.CodeInternal, "malformed compiled group key pair %q", gkey)
|
||||
}
|
||||
builder.Add(p[0], p[1])
|
||||
}
|
||||
builder.Sort()
|
||||
return builder.Labels(), nil
|
||||
}
|
||||
|
||||
// applyScalarOps applies the number-literal op pipeline in place, with the
|
||||
// same float64 arithmetic and comparison-filter semantics as the engine.
|
||||
func applyScalarOps(ops []scalarOp, values []*float64) {
|
||||
for _, op := range ops {
|
||||
for i, v := range values {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
lhs, rhs := *v, op.scalar
|
||||
if op.scalarOnLeft {
|
||||
lhs, rhs = op.scalar, *v
|
||||
}
|
||||
switch op.op {
|
||||
case parser.ADD:
|
||||
res := lhs + rhs
|
||||
values[i] = &res
|
||||
case parser.SUB:
|
||||
res := lhs - rhs
|
||||
values[i] = &res
|
||||
case parser.MUL:
|
||||
res := lhs * rhs
|
||||
values[i] = &res
|
||||
case parser.DIV:
|
||||
res := lhs / rhs
|
||||
values[i] = &res
|
||||
case parser.MOD:
|
||||
res := math.Mod(lhs, rhs)
|
||||
values[i] = &res
|
||||
case parser.POW:
|
||||
res := math.Pow(lhs, rhs)
|
||||
values[i] = &res
|
||||
default:
|
||||
keep := compare(op.op, lhs, rhs)
|
||||
switch {
|
||||
case op.returnBool:
|
||||
res := 0.0
|
||||
if keep {
|
||||
res = 1.0
|
||||
}
|
||||
values[i] = &res
|
||||
case keep:
|
||||
// Filter comparisons keep the vector-side value.
|
||||
vec := *v
|
||||
values[i] = &vec
|
||||
default:
|
||||
values[i] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func compare(op parser.ItemType, lhs, rhs float64) bool {
|
||||
switch op {
|
||||
case parser.EQLC:
|
||||
return lhs == rhs
|
||||
case parser.NEQ:
|
||||
return lhs != rhs
|
||||
case parser.GTR:
|
||||
return lhs > rhs
|
||||
case parser.LSS:
|
||||
return lhs < rhs
|
||||
case parser.GTE:
|
||||
return lhs >= rhs
|
||||
case parser.LTE:
|
||||
return lhs <= rhs
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toMatrix converts a unit result to a promql matrix on the query grid.
|
||||
func toMatrix(series []transpiledSeries, startMs, stepMs int64) promql.Matrix {
|
||||
matrix := make(promql.Matrix, 0, len(series))
|
||||
for _, s := range series {
|
||||
var floats []promql.FPoint
|
||||
for i, v := range s.values {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
floats = append(floats, promql.FPoint{T: startMs + int64(i)*stepMs, F: *v})
|
||||
}
|
||||
if len(floats) == 0 {
|
||||
continue
|
||||
}
|
||||
matrix = append(matrix, promql.Series{Metric: s.lset, Floats: floats})
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
// executeHybrid substitutes each unit's grids into the engine as synthetic
|
||||
// series and evaluates the rewritten query over a storage that serves
|
||||
// synthetic selectors from memory and everything else from the live querier.
|
||||
// Absent grid points become stale markers so the engine's lookback cannot
|
||||
// resurrect the previous grid point. Each unit's synthetic samples sit on its
|
||||
// own grid (query grid, or subquery grid for units inside subqueries).
|
||||
func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, results [][]transpiledSeries) (promql.Matrix, error) {
|
||||
synthetic := make(map[string][]*series, len(plan.units))
|
||||
staleMarker := math.Float64frombits(promValue.StaleNaN)
|
||||
|
||||
queryGrid := plan.grid
|
||||
|
||||
for i, unit := range plan.units {
|
||||
g := unit.grid
|
||||
gridLen := 1
|
||||
if g.stepMs > 0 {
|
||||
gridLen = int((g.endMs-g.startMs)/g.stepMs) + 1
|
||||
}
|
||||
list := make([]*series, 0, len(results[i]))
|
||||
for _, cs := range results[i] {
|
||||
builder := labels.NewBuilder(cs.lset)
|
||||
builder.Set(metricNameLabel, unit.name)
|
||||
s := &series{lset: builder.Labels()}
|
||||
s.ts = make([]int64, 0, gridLen)
|
||||
s.vs = make([]float64, 0, gridLen)
|
||||
for idx := 0; idx < gridLen; idx++ {
|
||||
t := g.startMs + int64(idx)*g.stepMs
|
||||
var v float64
|
||||
if idx < len(cs.values) && cs.values[idx] != nil {
|
||||
v = *cs.values[idx]
|
||||
} else {
|
||||
v = staleMarker
|
||||
}
|
||||
s.ts = append(s.ts, t)
|
||||
s.vs = append(s.vs, v)
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
synthetic[unit.name] = list
|
||||
}
|
||||
|
||||
hybrid := &hybridQueryable{client: e.client, synthetic: synthetic}
|
||||
|
||||
var qry promql.Query
|
||||
var err error
|
||||
if queryGrid.stepMs == 0 {
|
||||
qry, err = e.engine.NewInstantQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.endMs))
|
||||
} else {
|
||||
qry, err = e.engine.NewRangeQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.startMs), time.UnixMilli(queryGrid.endMs), time.Duration(queryGrid.stepMs)*time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer qry.Close()
|
||||
|
||||
res := qry.Exec(ctx)
|
||||
if res.Err != nil {
|
||||
return nil, res.Err
|
||||
}
|
||||
|
||||
matrix, err := resultToMatrix(res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Deep-copy before Close returns the result's slices to the engine pool,
|
||||
// and drop the synthetic __name__ that filter comparisons preserve.
|
||||
out := make(promql.Matrix, 0, len(matrix))
|
||||
for _, s := range matrix {
|
||||
lset := s.Metric
|
||||
if name := lset.Get(metricNameLabel); len(name) >= len(syntheticNamePrefix) && name[:len(syntheticNamePrefix)] == syntheticNamePrefix {
|
||||
builder := labels.NewBuilder(lset)
|
||||
builder.Del(metricNameLabel)
|
||||
lset = builder.Labels()
|
||||
}
|
||||
floats := make([]promql.FPoint, len(s.Floats))
|
||||
copy(floats, s.Floats)
|
||||
out = append(out, promql.Series{Metric: lset.Copy(), Floats: floats})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resultToMatrix(res *promql.Result) (promql.Matrix, error) {
|
||||
switch v := res.Value.(type) {
|
||||
case promql.Matrix:
|
||||
return v, nil
|
||||
case promql.Vector:
|
||||
matrix := make(promql.Matrix, 0, len(v))
|
||||
for _, s := range v {
|
||||
matrix = append(matrix, promql.Series{Metric: s.Metric, Floats: []promql.FPoint{{T: s.T, F: s.F}}})
|
||||
}
|
||||
return matrix, nil
|
||||
case promql.Scalar:
|
||||
return promql.Matrix{{Metric: labels.EmptyLabels(), Floats: []promql.FPoint{{T: v.T, F: v.V}}}}, nil
|
||||
default:
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "unexpected hybrid result type %T", res.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// hybridQueryable serves synthetic (compiled) selectors from memory and
|
||||
// everything else from the live storage.
|
||||
type hybridQueryable struct {
|
||||
client *client
|
||||
synthetic map[string][]*series
|
||||
}
|
||||
|
||||
func (h *hybridQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
|
||||
return &hybridQuerier{
|
||||
querier: querier{mint: mint, maxt: maxt, client: h.client},
|
||||
synthetic: h.synthetic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type hybridQuerier struct {
|
||||
querier
|
||||
synthetic map[string][]*series
|
||||
}
|
||||
|
||||
func (h *hybridQuerier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
|
||||
if name, ok := isSyntheticSelector(matchers); ok {
|
||||
list := h.synthetic[name]
|
||||
if sortSeries {
|
||||
sorted := make([]*series, len(list))
|
||||
copy(sorted, list)
|
||||
sort.Slice(sorted, func(i, j int) bool { return labels.Compare(sorted[i].lset, sorted[j].lset) < 0 })
|
||||
list = sorted
|
||||
}
|
||||
return newSeriesSet(list)
|
||||
}
|
||||
return h.querier.Select(ctx, sortSeries, hints, matchers...)
|
||||
}
|
||||
303
pkg/prometheus/clickhouseprometheusv2/transpiler_sql.go
Normal file
303
pkg/prometheus/clickhouseprometheusv2/transpiler_sql.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// experimental gate for the timeSeries*ToGrid aggregate functions; attached
|
||||
// as a SETTINGS clause so telemetrystore hooks cannot clobber it.
|
||||
const gridFunctionsSetting = "SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1"
|
||||
|
||||
var aggForEach = map[string]string{
|
||||
"sum": "sumForEach",
|
||||
"min": "minForEach",
|
||||
"max": "maxForEach",
|
||||
"avg": "avgForEach",
|
||||
"count": "countForEach",
|
||||
}
|
||||
|
||||
// buildUnitSQL renders the single ClickHouse query evaluating a core unit
|
||||
// over the [startMs, endMs] / stepMs evaluation grid: per-series grids via a
|
||||
// timeSeries*ToGrid aggregate (or a windowed aggregation for *_over_time),
|
||||
// then spatial aggregation with -ForEach combinators grouped by a canonical
|
||||
// JSON key of the projected label pairs.
|
||||
//
|
||||
// The heavy level is shaped to run on the shards: the top-level FROM is the
|
||||
// distributed samples table and the group-key join partner is a subquery on
|
||||
// the shard-local time series table, so the shard rewrite executes the join
|
||||
// and the per-(fingerprint, gkey) grid aggregation next to the data —
|
||||
// complete by fingerprint co-locality (see localTimeSeriesTable) — and the
|
||||
// initiator only merges per-series grid states and applies the spatial
|
||||
// -ForEach step. Same layout as the telemetrymetrics statement builder. The
|
||||
// windowed *_over_time form is the exception: its ARRAY JOIN level
|
||||
// aggregates on the shards the same way, but the group-key join happens at
|
||||
// the initiator over the already-reduced per-(series, index) rows — pushing
|
||||
// it down would not move any data off the initiator (the reduced rows arrive
|
||||
// there either way), so the combined ARRAY JOIN + JOIN form buys nothing.
|
||||
//
|
||||
// inlineFingerprints carries the matched set when it fits the inline limit;
|
||||
// nil means over the limit, where the group-key join restricts on its own
|
||||
// (the windowed form, whose fan-out query has no join, falls back to a
|
||||
// shard-local semi-join so it does not expand every series of the metric).
|
||||
//
|
||||
// The selector's data window is offset-shifted; the resulting grid indices
|
||||
// map 1:1 onto the query grid (output ts = startMs + i*stepMs). Grid
|
||||
// parameters are rendered as literals — they are aggregate-function
|
||||
// parameters, not bindable values.
|
||||
//
|
||||
// Statements nest builder-rendered SQL as text, so the returned args must be
|
||||
// ordered by where each fragment lands in the final statement: ClickHouse
|
||||
// binds ? placeholders by position. A JOIN renders before WHERE, so a joined
|
||||
// subquery's args precede the outer query's own condition args.
|
||||
//
|
||||
// Row shape: (gkey String, grid Array(Nullable(Float64))). gkey is
|
||||
// toJSONString of the sorted projected [key, value] pairs; NULL grid points
|
||||
// are absent points (the engine's "no value here"), which the -ForEach
|
||||
// combinators preserve: an index where every series is NULL aggregates to
|
||||
// NULL, and countForEach's 0 is mapped back to NULL.
|
||||
func buildUnitSQL(unit *coreUnit, metricNames []string, inlineFingerprints []uint64, dataStart, dataEnd int64, startMs, endMs, stepMs, lookbackMs int64) (string, []any, error) {
|
||||
selStart := startMs - unit.offsetMs
|
||||
selEnd := endMs - unit.offsetMs
|
||||
stepSec := stepMs / 1000
|
||||
if stepSec == 0 {
|
||||
// Instant query: start == end, so the grid has one point for any
|
||||
// positive step.
|
||||
stepSec = 1
|
||||
}
|
||||
windowMs := unit.rangeMs
|
||||
if unit.kind == unitInstant {
|
||||
windowMs = lookbackMs
|
||||
}
|
||||
windowSec := windowMs / 1000
|
||||
|
||||
adjustedTsStart, tsTable := timeSeriesTableFor(dataStart, dataEnd)
|
||||
|
||||
// seriesSub computes fingerprint -> group key. It reads the local series
|
||||
// table when it rides inside the shard-rewritten samples query, and the
|
||||
// distributed one when it joins at the initiator (windowed form).
|
||||
seriesSub := func(table string) (string, []any, error) {
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
sub.Select("fingerprint", groupKeyExpr(unit)+" AS gkey")
|
||||
sub.From(fmt.Sprintf("%s.%s", databaseName, table))
|
||||
if err := applySeriesConditions(sub, adjustedTsStart, dataEnd, unit.matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sub.GroupBy("fingerprint", "gkey")
|
||||
q, args := sub.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return q, args, nil
|
||||
}
|
||||
|
||||
// samplesConditions adds the samples-side WHERE. The samples table is
|
||||
// aliased "points" in every kind: under the group-key join both sides
|
||||
// carry a fingerprint column, so the filter must qualify it. A nil
|
||||
// inline set adds no fingerprint condition — the join restricts.
|
||||
samplesConditions := func(sb *sqlbuilder.SelectBuilder, excludeStale bool) {
|
||||
switch len(metricNames) {
|
||||
case 0:
|
||||
// No name constraint derivable; correct but unable to use the
|
||||
// metric_name primary-key prefix.
|
||||
case 1:
|
||||
sb.Where(sb.EQ("metric_name", metricNames[0]))
|
||||
default:
|
||||
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
|
||||
}
|
||||
// temporality precedes metric_name in the samples primary key; the
|
||||
// fingerprints already come from these temporalities, so this only
|
||||
// helps granule pruning.
|
||||
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
|
||||
if inlineFingerprints != nil {
|
||||
sb.Where("points.fingerprint " + inlineFingerprintFilter(inlineFingerprints))
|
||||
}
|
||||
// Left-open window: a sample exactly at the window's lower boundary
|
||||
// is never used (range selectors and lookback are both left-open).
|
||||
sb.Where(sb.GT("unix_milli", selStart-windowMs), sb.LTE("unix_milli", selEnd))
|
||||
if excludeStale {
|
||||
// PromQL excludes stale markers from range vectors. Instant
|
||||
// selectors need the stale rows for shadowing instead.
|
||||
sb.Where("bitAnd(flags, 1) = 0")
|
||||
}
|
||||
}
|
||||
|
||||
// joinedInner builds the shard-side SELECT for the single-pass kinds:
|
||||
// grid expression per (fingerprint, gkey), group-key join against the
|
||||
// local series table.
|
||||
joinedInner := func(gridExpr string, excludeStale bool) (string, []any, error) {
|
||||
seriesSQL, seriesArgs, err := seriesSub(localTimeSeriesTable(tsTable))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("series.gkey AS gkey", gridExpr+" AS grid")
|
||||
sb.From(fmt.Sprintf("%s.%s AS points", databaseName, distributedSamplesV4))
|
||||
sb.JoinWithOption(sqlbuilder.InnerJoin, fmt.Sprintf("(%s) AS series", seriesSQL), "points.fingerprint = series.fingerprint")
|
||||
samplesConditions(sb, excludeStale)
|
||||
sb.GroupBy("points.fingerprint", "series.gkey")
|
||||
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
// The join text renders before WHERE: its args come first.
|
||||
return q, append(seriesArgs, args...), nil
|
||||
}
|
||||
|
||||
var inner string
|
||||
var innerArgs []any
|
||||
var err error
|
||||
switch unit.kind {
|
||||
case unitInstant:
|
||||
// Instant selection with stale shadowing: the grid value is the last
|
||||
// non-stale sample in (t-lookback, t], absent when the overall last
|
||||
// sample in that window is a stale marker (verified semantics: the
|
||||
// -If combinator applies to the grid aggregates, and NULL comparisons
|
||||
// make a stale-latest point absent).
|
||||
gridParams := fmt.Sprintf("(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)", selStart, selEnd, stepSec, windowSec)
|
||||
gridExpr := fmt.Sprintf(
|
||||
"arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok), timeSeriesLastToGrid%s(fromUnixTimestamp64Milli(unix_milli), toFloat64(unix_milli)), timeSeriesLastToGridIf%s(fromUnixTimestamp64Milli(unix_milli), toFloat64(unix_milli), bitAnd(flags, 1) = 0), timeSeriesLastToGridIf%s(fromUnixTimestamp64Milli(unix_milli), value, bitAnd(flags, 1) = 0))",
|
||||
gridParams, gridParams, gridParams,
|
||||
)
|
||||
inner, innerArgs, err = joinedInner(gridExpr, false)
|
||||
case unitOverTime:
|
||||
if unit.overFn == "last" {
|
||||
// last_over_time == last non-stale sample in the window: the
|
||||
// stale rows are already excluded in WHERE.
|
||||
gridExpr := fmt.Sprintf(
|
||||
"timeSeriesLastToGrid(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)(fromUnixTimestamp64Milli(unix_milli), value)",
|
||||
selStart, selEnd, stepSec, windowSec,
|
||||
)
|
||||
inner, innerArgs, err = joinedInner(gridExpr, true)
|
||||
break
|
||||
}
|
||||
inner, innerArgs, err = windowedInner(unit, samplesConditions, seriesSub, inlineFingerprints == nil, adjustedTsStart, dataEnd, tsTable, selStart, selEnd, stepMs, windowMs)
|
||||
default: // unitRange
|
||||
gridExpr := fmt.Sprintf(
|
||||
"%s(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)(fromUnixTimestamp64Milli(unix_milli), value)",
|
||||
gridFunction[unit.fn], selStart, selEnd, stepSec, windowSec,
|
||||
)
|
||||
if unit.fn == fnIncrease {
|
||||
// increase == rate * range-seconds, exactly: extrapolatedRate
|
||||
// divides by the range only when isRate.
|
||||
gridExpr = fmt.Sprintf("arrayMap(x -> x * %d, %s)", windowSec, gridExpr)
|
||||
}
|
||||
inner, innerArgs, err = joinedInner(gridExpr, true)
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
spatial := "maxForEach(grid)"
|
||||
switch {
|
||||
case !unit.hasAgg:
|
||||
// Per-series output: one row per (labels-minus-__name__) group.
|
||||
// Distinct fingerprints can collapse onto the same projected label
|
||||
// set only via a regex __name__ selector over metrics with identical
|
||||
// other labels; maxForEach is a deterministic NULL-skipping merge and
|
||||
// the identity for the overwhelmingly common one-fingerprint group.
|
||||
case unit.aggOp.String() == "count":
|
||||
// count over an all-absent index is an absent point, not 0.
|
||||
spatial = "arrayMap(c -> if(c = 0, NULL, toFloat64(c)), countForEach(grid))"
|
||||
default:
|
||||
spatial = fmt.Sprintf("%s(grid)", aggForEach[unit.aggOp.String()])
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT gkey, %s AS grid FROM (%s) GROUP BY gkey %s", spatial, inner, gridFunctionsSetting)
|
||||
return query, innerArgs, nil
|
||||
}
|
||||
|
||||
// windowedInner builds the avg/min/max/sum/count _over_time form: each
|
||||
// sample fans out to every grid index k whose window (t_k - range, t_k]
|
||||
// contains it (ARRAY JOIN), aggregates per (fingerprint, k) — shard-side
|
||||
// partials over the distributed table — then assembles the positional grid
|
||||
// and joins the group key at the initiator over the reduced rows. The
|
||||
// group-key subquery reads the distributed series table here because it does
|
||||
// not ride inside a shard-rewritten query.
|
||||
//
|
||||
// This is the one form whose samples query has no series join, so an
|
||||
// over-the-limit fingerprint set (semiJoin) must fall back to the
|
||||
// shard-local semi-join: without it the fan-out would expand every series of
|
||||
// the metric and discard the unmatched ones only at the group-key join.
|
||||
func windowedInner(unit *coreUnit, samplesConditions func(*sqlbuilder.SelectBuilder, bool), seriesSub func(string) (string, []any, error), semiJoin bool, adjustedTsStart, dataEnd int64, tsTable string, selStart, selEnd, stepMs, windowMs int64) (string, []any, error) {
|
||||
aggExpr := map[string]string{
|
||||
"avg": "avg(value)",
|
||||
"min": "min(value)",
|
||||
"max": "max(value)",
|
||||
"sum": "sum(value)",
|
||||
"count": "toFloat64(count(value))",
|
||||
}[unit.overFn]
|
||||
effStepMs := stepMs
|
||||
if effStepMs == 0 {
|
||||
effStepMs = 1000
|
||||
}
|
||||
lastIdx := (selEnd - selStart) / effStepMs
|
||||
|
||||
perWindow := sqlbuilder.NewSelectBuilder()
|
||||
perWindow.Select("fingerprint", "k", aggExpr+" AS v")
|
||||
perWindow.From(fmt.Sprintf(
|
||||
"%s.%s AS points ARRAY JOIN range(toUInt64(greatest(0, intDiv(unix_milli - %d + %d - 1, %d))), toUInt64(least(%d, intDiv(unix_milli + %d - 1 - %d, %d)) + 1)) AS k",
|
||||
databaseName, distributedSamplesV4,
|
||||
selStart, effStepMs, effStepMs,
|
||||
lastIdx, windowMs, selStart, effStepMs,
|
||||
))
|
||||
samplesConditions(perWindow, true)
|
||||
if semiJoin {
|
||||
sub := sqlbuilder.NewSelectBuilder()
|
||||
sub.Select("fingerprint")
|
||||
sub.From(fmt.Sprintf("%s.%s", databaseName, localTimeSeriesTable(tsTable)))
|
||||
if err := applySeriesConditions(sub, adjustedTsStart, dataEnd, unit.matchers); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
perWindow.Where(perWindow.In("points.fingerprint", sub))
|
||||
}
|
||||
perWindow.GroupBy("fingerprint", "k")
|
||||
perWindowSQL, perWindowArgs := perWindow.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
grids := fmt.Sprintf(
|
||||
"SELECT fingerprint, arrayMap(i -> if(indexOf(ks, i) = 0, NULL, vs[indexOf(ks, i)]), range(toUInt64(%d))) AS grid FROM (SELECT fingerprint, groupArray(k) AS ks, groupArray(v) AS vs FROM (%s) GROUP BY fingerprint)",
|
||||
lastIdx+1, perWindowSQL,
|
||||
)
|
||||
|
||||
seriesSQL, seriesArgs, err := seriesSub(tsTable)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
inner := fmt.Sprintf(
|
||||
"SELECT series.gkey AS gkey, points.grid AS grid FROM (%s) AS points INNER JOIN (%s) AS series ON points.fingerprint = series.fingerprint",
|
||||
grids, seriesSQL,
|
||||
)
|
||||
return inner, append(perWindowArgs, seriesArgs...), nil
|
||||
}
|
||||
|
||||
// groupKeyExpr renders the canonical group key for a unit: the sorted
|
||||
// [key, value] pairs of the projected labels, JSON-encoded.
|
||||
// - by (a, b): keep only the listed labels (absent labels stay absent,
|
||||
// matching PromQL's by() over missing labels);
|
||||
// - without (a, b): keep everything except the listed labels and __name__;
|
||||
// - no aggregation: keep everything including __name__ — even when the
|
||||
// unit drops the name from its OUTPUT, the key must keep it so distinct
|
||||
// metrics never merge in SQL; executeUnit strips the name afterwards and
|
||||
// turns a post-strip collision into the engine's duplicate-labelset
|
||||
// error instead of a silently invented merge.
|
||||
func groupKeyExpr(unit *coreUnit) string {
|
||||
// An empty label value means "label absent" in Prometheus; the stored
|
||||
// labels JSON can carry empty attribute values, which must not become
|
||||
// output labels or group keys.
|
||||
pairs := "arraySort(JSONExtractKeysAndValues(labels, 'String'))"
|
||||
if !unit.hasAgg {
|
||||
return fmt.Sprintf("toJSONString(arrayFilter(p -> p.2 != '', %s))", pairs)
|
||||
}
|
||||
if unit.by {
|
||||
if len(unit.grouping) == 0 {
|
||||
return "'[]'"
|
||||
}
|
||||
return fmt.Sprintf("toJSONString(arrayFilter(p -> p.2 != '' AND p.1 IN (%s), %s))", quotedList(unit.grouping), pairs)
|
||||
}
|
||||
excluded := append([]string{metricNameLabel}, unit.grouping...)
|
||||
return fmt.Sprintf("toJSONString(arrayFilter(p -> p.2 != '' AND p.1 NOT IN (%s), %s))", quotedList(excluded), pairs)
|
||||
}
|
||||
|
||||
func quotedList(items []string) string {
|
||||
quoted := make([]string, len(items))
|
||||
for i, s := range items {
|
||||
quoted[i] = "'" + strings.ReplaceAll(s, "'", "\\'") + "'"
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
517
pkg/prometheus/clickhouseprometheusv2/transpiler_test.go
Normal file
517
pkg/prometheus/clickhouseprometheusv2/transpiler_test.go
Normal file
@@ -0,0 +1,517 @@
|
||||
package clickhouseprometheusv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func parse(t *testing.T, q string) parser.Expr {
|
||||
t.Helper()
|
||||
expr, err := parser.NewParser(parser.Options{}).ParseExpr(q)
|
||||
require.NoError(t, err)
|
||||
return expr
|
||||
}
|
||||
|
||||
func TestClassifyFullShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
check func(t *testing.T, u *coreUnit)
|
||||
}{
|
||||
{
|
||||
name: "sum by rate",
|
||||
query: `sum by (pod) (rate(http_requests_total{job="api"}[5m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnRate, u.fn)
|
||||
assert.Equal(t, int64(300_000), u.rangeMs)
|
||||
assert.True(t, u.hasAgg)
|
||||
assert.True(t, u.by)
|
||||
assert.Equal(t, []string{"pod"}, u.grouping)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare increase with offset",
|
||||
query: `increase(errors_total[10m] offset 30m)`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnIncrease, u.fn)
|
||||
assert.Equal(t, int64(1_800_000), u.offsetMs)
|
||||
assert.False(t, u.hasAgg)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "avg without over delta",
|
||||
query: `avg without (instance) (delta(gauge_metric[15m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnDelta, u.fn)
|
||||
assert.True(t, u.hasAgg)
|
||||
assert.False(t, u.by)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scalar pipeline with comparison",
|
||||
query: `sum(rate(x[5m])) * 100 > 5`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
require.Len(t, u.ops, 2)
|
||||
assert.Equal(t, parser.ItemType(parser.MUL), u.ops[0].op)
|
||||
assert.Equal(t, 100.0, u.ops[0].scalar)
|
||||
assert.Equal(t, parser.ItemType(parser.GTR), u.ops[1].op)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scalar on left with unary minus",
|
||||
query: `-1 * sum(rate(x[5m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
require.Len(t, u.ops, 1)
|
||||
assert.True(t, u.ops[0].scalarOnLeft)
|
||||
assert.Equal(t, -1.0, u.ops[0].scalar)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bool comparison",
|
||||
query: `sum(rate(x[5m])) >= bool 0.5`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
require.Len(t, u.ops, 1)
|
||||
assert.True(t, u.ops[0].returnBool)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "irate utf8 name",
|
||||
query: `sum by ("k8s.pod.name") (irate({"k8s.container.cpu.time"}[2m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, fnIRate, u.fn)
|
||||
assert.Equal(t, []string{"k8s.pod.name"}, u.grouping)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bare instant selector keeps name",
|
||||
query: `up{job="api"}`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.True(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge aggregation",
|
||||
query: `sum by (pod) (container_memory offset 5m)`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.Equal(t, int64(300_000), u.offsetMs)
|
||||
assert.True(t, u.hasAgg)
|
||||
assert.False(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge comparison keeps name",
|
||||
query: `container_memory > 100`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.True(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gauge arithmetic drops name",
|
||||
query: `container_memory / 1024`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitInstant, u.kind)
|
||||
assert.False(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "avg_over_time",
|
||||
query: `max by (node) (avg_over_time(load1[10m]))`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitOverTime, u.kind)
|
||||
assert.Equal(t, "avg", u.overFn)
|
||||
assert.Equal(t, int64(600_000), u.rangeMs)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "last_over_time keeps name",
|
||||
query: `last_over_time(load1[10m])`,
|
||||
check: func(t *testing.T, u *coreUnit) {
|
||||
assert.Equal(t, unitOverTime, u.kind)
|
||||
assert.Equal(t, "last", u.overFn)
|
||||
assert.True(t, u.keepsName())
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, tt.query), testGrid(60_000))
|
||||
require.True(t, ok, "expected transpilable")
|
||||
require.True(t, plan.full, "expected full compilation")
|
||||
require.Len(t, plan.units, 1)
|
||||
tt.check(t, &plan.units[0].core)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyFallbackShapes(t *testing.T) {
|
||||
queries := []struct {
|
||||
name string
|
||||
query string
|
||||
step int64
|
||||
}{
|
||||
{"default-resolution subquery", `max_over_time(rate(x[5m])[30m:])`, 60_000},
|
||||
{"at modifier", `sum(rate(x[5m] @ 1609746000))`, 60_000},
|
||||
{"at modifier on gauge", `sum(container_memory @ 1609746000)`, 60_000},
|
||||
{"sub-second step", `sum(rate(x[5m]))`, 500},
|
||||
{"sub-second range", `sum(rate(x[1500ms]))`, 60_000},
|
||||
{"by __name__ full", `sum by (__name__) (rate({__name__=~"a|b"}[5m]))`, 60_000},
|
||||
{"quantile_over_time unsupported", `quantile_over_time(0.9, load1[10m])`, 60_000},
|
||||
}
|
||||
for _, tt := range queries {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, ok := classify(parse(t, tt.query), testGrid(tt.step))
|
||||
assert.False(t, ok, "expected fallback for %s", tt.query)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyHybridShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantUnits int
|
||||
wantRewritten string
|
||||
}{
|
||||
{
|
||||
name: "histogram quantile",
|
||||
query: `histogram_quantile(0.95, sum by (le) (rate(http_bucket[5m])))`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `histogram_quantile(0.95, __signoz_transpiled_0__)`,
|
||||
},
|
||||
{
|
||||
name: "topk over compiled",
|
||||
query: `topk(5, sum by (pod) (rate(x[5m])))`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `topk(5, __signoz_transpiled_0__)`,
|
||||
},
|
||||
{
|
||||
name: "ratio of compiled units",
|
||||
query: `sum(rate(a[5m])) / sum(rate(b[5m]))`,
|
||||
wantUnits: 2,
|
||||
wantRewritten: `__signoz_transpiled_0__ / __signoz_transpiled_1__`,
|
||||
},
|
||||
{
|
||||
name: "or vector zero",
|
||||
query: `sum(rate(a[5m])) or vector(0)`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `__signoz_transpiled_0__ or vector(0)`,
|
||||
},
|
||||
{
|
||||
name: "quantile agg over compiled rate",
|
||||
query: `quantile(0.9, rate(x[5m]))`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `quantile(0.9, __signoz_transpiled_0__)`,
|
||||
},
|
||||
{
|
||||
name: "non-literal scalar side stays engine-side",
|
||||
query: `sum(rate(x[5m])) * scalar(y)`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `__signoz_transpiled_0__ * scalar(y)`,
|
||||
},
|
||||
{
|
||||
name: "compiled mixed with raw selector",
|
||||
query: `sum by (pod) (rate(a[5m])) / on (pod) group_left () b`,
|
||||
wantUnits: 1,
|
||||
wantRewritten: `__signoz_transpiled_0__ / on (pod) group_left () b`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, tt.query), testGrid(60_000))
|
||||
require.True(t, ok)
|
||||
assert.False(t, plan.full)
|
||||
assert.Len(t, plan.units, tt.wantUnits)
|
||||
assert.Equal(t, tt.wantRewritten, plan.rewritten)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyHybridGuards(t *testing.T) {
|
||||
t.Run("no substitution under on(__name__)", func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, `sum(rate(a[5m])) * on (__name__) b`), testGrid(60_000))
|
||||
_ = plan
|
||||
assert.False(t, ok, "matching on __name__ must not see synthetic names")
|
||||
})
|
||||
t.Run("no substitution inside @-pinned subquery", func(t *testing.T) {
|
||||
_, ok := classify(parse(t, `max_over_time(rate(x[5m])[30m:1m] @ 1609746000)`), testGrid(60_000))
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
// The alert-smoothing idiom: units inside a fixed-resolution subquery
|
||||
// evaluate on the subquery grid — epoch-aligned multiples of the resolution,
|
||||
// starting strictly after (outer start - range), exactly as the engine
|
||||
// derives it.
|
||||
func TestClassifySubqueryUnits(t *testing.T) {
|
||||
grid := gridContext{startMs: 1_700_000_030_000, endMs: 1_700_007_200_000, stepMs: 60_000}
|
||||
|
||||
plan, ok := classify(parse(t, `min_over_time((sum by (ns) (increase(x[5m])))[10m:5m]) > 0`), grid)
|
||||
require.True(t, ok)
|
||||
require.False(t, plan.full)
|
||||
require.Len(t, plan.units, 1)
|
||||
assert.Equal(t, `min_over_time(__signoz_transpiled_0__[10m:5m]) > 0`, plan.rewritten)
|
||||
|
||||
unit := plan.units[0]
|
||||
// lower bound = outer start - range = 1_699_999_430_000; first multiple
|
||||
// of 300_000 strictly greater is 1_699_999_500_000.
|
||||
assert.Equal(t, int64(1_699_999_500_000), unit.grid.startMs)
|
||||
assert.Equal(t, grid.endMs, unit.grid.endMs)
|
||||
assert.Equal(t, int64(300_000), unit.grid.stepMs)
|
||||
assert.Equal(t, fnIncrease, unit.core.fn)
|
||||
|
||||
t.Run("subquery offset shifts the grid", func(t *testing.T) {
|
||||
plan, ok := classify(parse(t, `max_over_time((sum(rate(x[5m])))[10m:5m] offset 30m)`), grid)
|
||||
require.True(t, ok)
|
||||
require.Len(t, plan.units, 1)
|
||||
// lower = start - offset - range = 1_699_997_630_000 -> first
|
||||
// multiple of 300_000 above = 1_699_997_700_000; end shifts too.
|
||||
assert.Equal(t, int64(1_699_997_700_000), plan.units[0].grid.startMs)
|
||||
assert.Equal(t, grid.endMs-1_800_000, plan.units[0].grid.endMs)
|
||||
})
|
||||
|
||||
t.Run("mollusk ratio-inside-subquery idiom", func(t *testing.T) {
|
||||
q := `min_over_time(((sum by (a) (rate(m1[5m]))) / (avg by (a) (m2)))[5m:1m])`
|
||||
plan, ok := classify(parse(t, q), grid)
|
||||
require.True(t, ok)
|
||||
// Both sides compile on the subquery grid: the rate side and the
|
||||
// gauge aggregation side; the engine joins them and smooths.
|
||||
require.Len(t, plan.units, 2)
|
||||
assert.Equal(t, int64(60_000), plan.units[0].grid.stepMs)
|
||||
assert.Equal(t, unitInstant, plan.units[1].core.kind)
|
||||
assert.Contains(t, plan.rewritten, `__signoz_transpiled_0__ / __signoz_transpiled_1__`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildUnitSQL(t *testing.T) {
|
||||
unit := &coreUnit{
|
||||
fn: fnRate,
|
||||
rangeMs: 300_000,
|
||||
hasAgg: true,
|
||||
aggOp: parser.SUM,
|
||||
by: true,
|
||||
grouping: []string{"pod"},
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
|
||||
}
|
||||
sql, args, err := buildUnitSQL(unit, []string{"http_requests_total"}, []uint64{7, 42}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, sql, "timeSeriesRateToGrid(fromUnixTimestamp64Milli(1700000000000), fromUnixTimestamp64Milli(1700003600000), 60, 300)(fromUnixTimestamp64Milli(unix_milli), value)")
|
||||
assert.Contains(t, sql, "unix_milli > ? AND unix_milli <= ?")
|
||||
assert.Contains(t, sql, "bitAnd(flags, 1) = 0")
|
||||
assert.Contains(t, sql, "sumForEach(grid)")
|
||||
// The group-key join rides inside the shard query: distributed samples
|
||||
// at the top level, the local series table in the join subquery, the
|
||||
// grid aggregation grouped per (fingerprint, gkey) shard-side.
|
||||
assert.Contains(t, sql, "FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint,")
|
||||
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
|
||||
assert.Contains(t, sql, "GROUP BY points.fingerprint, series.gkey")
|
||||
assert.Contains(t, sql, "points.fingerprint IN (7, 42)")
|
||||
assert.Contains(t, sql, `toJSONString(arrayFilter(p -> p.2 != '' AND p.1 IN ('pod'),`)
|
||||
assert.Contains(t, sql, "SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1")
|
||||
// Args follow placeholder order: the joined series subquery renders
|
||||
// before the samples WHERE.
|
||||
assert.Equal(t, []any{"http_requests_total", int64(1_699_999_200_000), int64(1_700_003_600_000), "http_requests_total", int64(1_699_999_700_000), int64(1_700_003_600_000)}, args)
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLIncreaseAndOffset(t *testing.T) {
|
||||
unit := &coreUnit{
|
||||
fn: fnIncrease,
|
||||
rangeMs: 600_000,
|
||||
offsetMs: 1_800_000,
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "errors_total")},
|
||||
}
|
||||
sql, _, err := buildUnitSQL(unit, nil, []uint64{7}, 1_699_997_600_000, 1_700_001_800_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Grid and window shift by the offset; increase multiplies rate by the
|
||||
// range in seconds.
|
||||
assert.Contains(t, sql, "fromUnixTimestamp64Milli(1699998200000), fromUnixTimestamp64Milli(1700001800000)")
|
||||
assert.Contains(t, sql, "arrayMap(x -> x * 600, timeSeriesRateToGrid")
|
||||
assert.Contains(t, sql, "maxForEach(grid)")
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLOverLimitJoinOnly(t *testing.T) {
|
||||
// Past the inline limit no fingerprint filter is rendered: the series
|
||||
// join restricts to the matched fingerprints on its own.
|
||||
unit := &coreUnit{
|
||||
fn: fnRate,
|
||||
rangeMs: 300_000,
|
||||
hasAgg: true,
|
||||
aggOp: parser.SUM,
|
||||
by: true,
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
|
||||
}
|
||||
sql, _, err := buildUnitSQL(unit, []string{"http_requests_total"}, nil, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, sql, "points.fingerprint IN")
|
||||
assert.Contains(t, sql, "INNER JOIN (SELECT fingerprint,")
|
||||
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
|
||||
}
|
||||
|
||||
func TestBuildUnitSQLOverLimitWindowedSemiJoin(t *testing.T) {
|
||||
// The windowed *_over_time fan-out has no series join, so the over-limit
|
||||
// regime falls back to the shard-local semi-join instead of expanding
|
||||
// every series of the metric.
|
||||
unit := &coreUnit{
|
||||
kind: unitOverTime,
|
||||
overFn: "avg",
|
||||
rangeMs: 600_000,
|
||||
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "node_load1")},
|
||||
}
|
||||
sql, _, err := buildUnitSQL(unit, []string{"node_load1"}, nil, 1_699_999_400_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, sql, "points.fingerprint IN (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE ")
|
||||
assert.Contains(t, sql, "ARRAY JOIN range(")
|
||||
}
|
||||
|
||||
func TestApplyScalarOps(t *testing.T) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
|
||||
t.Run("arithmetic chain", func(t *testing.T) {
|
||||
values := []*float64{f(2), nil, f(4)}
|
||||
applyScalarOps([]scalarOp{{op: parser.MUL, scalar: 100}, {op: parser.ADD, scalar: 1}}, values)
|
||||
require.NotNil(t, values[0])
|
||||
assert.Equal(t, 201.0, *values[0])
|
||||
assert.Nil(t, values[1])
|
||||
assert.Equal(t, 401.0, *values[2])
|
||||
})
|
||||
|
||||
t.Run("comparison filters points", func(t *testing.T) {
|
||||
values := []*float64{f(1), f(10)}
|
||||
applyScalarOps([]scalarOp{{op: parser.GTR, scalar: 5}}, values)
|
||||
assert.Nil(t, values[0])
|
||||
require.NotNil(t, values[1])
|
||||
assert.Equal(t, 10.0, *values[1], "filter comparisons keep the original value")
|
||||
})
|
||||
|
||||
t.Run("bool comparison emits 0/1", func(t *testing.T) {
|
||||
values := []*float64{f(1), f(10)}
|
||||
applyScalarOps([]scalarOp{{op: parser.GTR, scalar: 5, returnBool: true}}, values)
|
||||
assert.Equal(t, 0.0, *values[0])
|
||||
assert.Equal(t, 1.0, *values[1])
|
||||
})
|
||||
|
||||
t.Run("scalar on left division", func(t *testing.T) {
|
||||
values := []*float64{f(4)}
|
||||
applyScalarOps([]scalarOp{{op: parser.DIV, scalar: 100, scalarOnLeft: true}}, values)
|
||||
assert.Equal(t, 25.0, *values[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestLabelsFromGroupKey(t *testing.T) {
|
||||
lset, err := labelsFromGroupKey(`[["pod","api-0"],["ns","prod"]]`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "api-0", lset.Get("pod"))
|
||||
assert.Equal(t, "prod", lset.Get("ns"))
|
||||
|
||||
empty, err := labelsFromGroupKey(`[]`)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, empty.IsEmpty())
|
||||
}
|
||||
|
||||
// testGrid is a 2h query grid ending on a round timestamp.
|
||||
func testGrid(stepMs int64) gridContext {
|
||||
return gridContext{startMs: 1_700_000_000_000, endMs: 1_700_007_200_000, stepMs: stepMs}
|
||||
}
|
||||
|
||||
// A bool comparison returns 0/1, not the sample, so the engine drops
|
||||
// __name__; keeping it would change downstream vector matching.
|
||||
func TestKeepsName_BoolComparisonDropsName(t *testing.T) {
|
||||
plan, ok := classify(parse(t, `up > bool 0`), testGrid(60_000))
|
||||
require.True(t, ok)
|
||||
assert.False(t, plan.units[0].core.keepsName())
|
||||
|
||||
plan, ok = classify(parse(t, `up > 0`), testGrid(60_000))
|
||||
require.True(t, ok)
|
||||
assert.True(t, plan.units[0].core.keepsName())
|
||||
}
|
||||
|
||||
// timeSeriesLastToGrid widens its window to max(window, step) — probed on
|
||||
// 25.12 — so Last-style units at window < step must fall back or they would
|
||||
// resurrect samples the engine's lookback already dropped.
|
||||
func TestTryExecuteRange_LastStyleWindowBelowStepFallsBack(t *testing.T) {
|
||||
c, _ := newTestClient(t, prometheus.ClickhouseV2Config{})
|
||||
e := &executor{client: c, parser: prometheus.NewParser()}
|
||||
|
||||
start := time.UnixMilli(1_700_000_000_000)
|
||||
end := time.UnixMilli(1_700_003_600_000)
|
||||
|
||||
_, ok, err := e.TryExecuteRange(context.Background(), `sum by (pod) (up)`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "instant selection at step > lookback must not transpile")
|
||||
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `last_over_time(up[10m])`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, ok, "last_over_time at range < step must not transpile")
|
||||
}
|
||||
|
||||
// Transpiled results never pass the engine's sample limiter, so the grid
|
||||
// cells (series x grid width) must be budgeted before the arrays exist —
|
||||
// otherwise a wide query rebuilds the OOM this provider exists to prevent.
|
||||
func TestExecuteUnit_GridCellBudget(t *testing.T) {
|
||||
c, store := newTestClient(t, prometheus.ClickhouseV2Config{MaxFetchedSamples: 100})
|
||||
e := &executor{client: c, parser: prometheus.NewParser()}
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{
|
||||
{uint64(1), `{"__name__":"up","instance":"a"}`},
|
||||
{uint64(2), `{"__name__":"up","instance":"b"}`},
|
||||
}))
|
||||
|
||||
plan, ok := classify(parse(t, `sum(rate(up[5m]))`), gridContext{startMs: 1_700_000_000_000, endMs: 1_700_003_600_000, stepMs: 60_000})
|
||||
require.True(t, ok)
|
||||
|
||||
var cells atomic.Int64
|
||||
// 2 series x 61 grid points = 122 cells > 100.
|
||||
_, err := e.executeUnit(context.Background(), &plan.units[0].core, plan.units[0].grid, &cells)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "budget refusal must be typed invalid input, got %v", err)
|
||||
}
|
||||
|
||||
// Two metrics collapsing onto one labelset after the name drop is the
|
||||
// engine's duplicate-labelset error; silently merging them would invent a
|
||||
// series no engine would produce.
|
||||
func TestExecuteUnit_NameCollisionErrors(t *testing.T) {
|
||||
c, store := newTestClient(t, prometheus.ClickhouseV2Config{})
|
||||
e := &executor{client: c, parser: prometheus.NewParser()}
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("^(?:a|b)$", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{
|
||||
{uint64(1), `{"__name__":"a","job":"x"}`},
|
||||
{uint64(2), `{"__name__":"b","job":"x"}`},
|
||||
}))
|
||||
store.Mock().ExpectQuery("SELECT gkey").
|
||||
WithArgs("^(?:a|b)$", int64(1_699_999_200_000), int64(1_700_003_600_000), "a", "b", int64(1_699_999_700_000), int64(1_700_003_600_000)).
|
||||
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{
|
||||
{`[["__name__","a"],["job","x"]]`, []*float64{f64(1)}},
|
||||
{`[["__name__","b"],["job","x"]]`, []*float64{f64(2)}},
|
||||
}))
|
||||
|
||||
plan, ok := classify(parse(t, `rate({__name__=~"a|b"}[5m])`), gridContext{startMs: 1_700_000_000_000, endMs: 1_700_003_600_000, stepMs: 60_000})
|
||||
require.True(t, ok)
|
||||
|
||||
var cells atomic.Int64
|
||||
_, err := e.executeUnit(context.Background(), &plan.units[0].core, plan.units[0].grid, &cells)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "vector cannot contain metrics with the same labelset")
|
||||
}
|
||||
|
||||
var gkeyCols = []cmock.ColumnType{
|
||||
{Name: "gkey", Type: "String"},
|
||||
{Name: "grid", Type: "Array(Nullable(Float64))"},
|
||||
}
|
||||
|
||||
func f64(v float64) *float64 { return &v }
|
||||
@@ -13,6 +13,11 @@ type ActiveQueryTrackerConfig struct {
|
||||
MaxConcurrent int `mapstructure:"max_concurrent"`
|
||||
}
|
||||
|
||||
type ClickhouseV2Config struct {
|
||||
MaxFetchedSeries int `mapstructure:"max_fetched_series"`
|
||||
MaxFetchedSamples int64 `mapstructure:"max_fetched_samples"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ActiveQueryTrackerConfig ActiveQueryTrackerConfig `mapstructure:"active_query_tracker"`
|
||||
|
||||
@@ -24,6 +29,13 @@ type Config struct {
|
||||
|
||||
// Timeout is the maximum time a query is allowed to run before being aborted.
|
||||
Timeout time.Duration `mapstructure:"timeout"`
|
||||
|
||||
// ProviderName selects the storage provider: "clickhouse" (default) or
|
||||
// "clickhousev2".
|
||||
ProviderName string `mapstructure:"provider"`
|
||||
|
||||
// ClickhouseV2 configures the clickhousev2 provider.
|
||||
ClickhouseV2 ClickhouseV2Config `mapstructure:"clickhousev2"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
@@ -37,7 +49,12 @@ func newConfig() factory.Config {
|
||||
Path: "",
|
||||
MaxConcurrent: 20,
|
||||
},
|
||||
Timeout: 2 * time.Minute,
|
||||
Timeout: 2 * time.Minute,
|
||||
ProviderName: "clickhouse",
|
||||
ClickhouseV2: ClickhouseV2Config{
|
||||
MaxFetchedSeries: 500_000,
|
||||
MaxFetchedSamples: 50_000_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +62,18 @@ func (c Config) Validate() error {
|
||||
if c.Timeout <= 0 {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::timeout must be greater than 0")
|
||||
}
|
||||
if c.ProviderName != "" && c.ProviderName != "clickhouse" && c.ProviderName != "clickhousev2" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::provider must be one of [clickhouse, clickhousev2], got %q", c.ProviderName)
|
||||
}
|
||||
if c.ClickhouseV2.MaxFetchedSeries < 0 || c.ClickhouseV2.MaxFetchedSamples < 0 {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::clickhousev2 limits must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) Provider() string {
|
||||
return "clickhouse"
|
||||
if c.ProviderName == "" {
|
||||
return "clickhouse"
|
||||
}
|
||||
return c.ProviderName
|
||||
}
|
||||
|
||||
@@ -35,3 +35,9 @@ type StatementRecorder interface {
|
||||
type StatementCapturer interface {
|
||||
CapturingStorage() (storage.Queryable, StatementRecorder)
|
||||
}
|
||||
|
||||
// ProviderClickhouseV2 is the clickhousev2 provider name: the factory
|
||||
// registration, the prometheus::provider config value and the
|
||||
// X-SigNoz-PromQL-Provider request header all use it, so they cannot drift
|
||||
// apart.
|
||||
const ProviderClickhouseV2 = "clickhousev2"
|
||||
|
||||
49
pkg/prometheus/traits.go
Normal file
49
pkg/prometheus/traits.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
type queryTraitsKey struct{}
|
||||
|
||||
// QueryTraits carries per-query facts a storage implementation cannot derive
|
||||
// from SelectHints alone. Call sites that parse the PromQL expression attach
|
||||
// traits to the context before handing it to the engine; storages treat a
|
||||
// missing traits value as "unknown" and stay conservative.
|
||||
type QueryTraits struct {
|
||||
// SubqueryFree is true when the query contains no subquery expression.
|
||||
// Subquery selectors are evaluated at the subquery's own step, but
|
||||
// SelectHints.Step always carries the top-level step, so step-aligned
|
||||
// storage optimizations (e.g. keeping only the last sample per step
|
||||
// bucket) are safe only when this is true.
|
||||
SubqueryFree bool
|
||||
}
|
||||
|
||||
// DetectQueryTraits derives QueryTraits from a parsed PromQL expression.
|
||||
func DetectQueryTraits(expr parser.Expr) QueryTraits {
|
||||
subqueryFree := true
|
||||
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
|
||||
if _, ok := node.(*parser.SubqueryExpr); ok {
|
||||
subqueryFree = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return QueryTraits{SubqueryFree: subqueryFree}
|
||||
}
|
||||
|
||||
// NewContextWithQueryTraits returns a context carrying the given traits.
|
||||
func NewContextWithQueryTraits(ctx context.Context, traits QueryTraits) context.Context {
|
||||
return context.WithValue(ctx, queryTraitsKey{}, traits)
|
||||
}
|
||||
|
||||
// QueryTraitsFromContext returns the traits attached to ctx, if any.
|
||||
//
|
||||
// Context is used here, unlike for backend selection, because traits must
|
||||
// cross the promql engine to reach storage.Querier.Select, and the engine's
|
||||
// interfaces offer no other channel; the alternative is a Prometheus fork.
|
||||
func QueryTraitsFromContext(ctx context.Context) (QueryTraits, bool) {
|
||||
traits, ok := ctx.Value(queryTraitsKey{}).(QueryTraits)
|
||||
return traits, ok
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
queryRangeRequest.PromQLProvider = req.Header.Get("X-SigNoz-PromQL-Provider")
|
||||
|
||||
// Validate the query request
|
||||
if err := queryRangeRequest.Validate(); err != nil {
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const traceOutsideRangeWarn = "Query %s references a trace_id that exists between %s and %s (UTC) but lies outside the selected time range; adjust the time range to see results"
|
||||
@@ -25,7 +24,6 @@ const traceOutsideRangeWarn = "Query %s references a trace_id that exists betwee
|
||||
type builderQuery[T any] struct {
|
||||
logger *slog.Logger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
orgID valuer.UUID
|
||||
stmtBuilder qbtypes.StatementBuilder[T]
|
||||
spec qbtypes.QueryBuilderQuery[T]
|
||||
variables map[string]qbtypes.VariableItem
|
||||
@@ -47,7 +45,6 @@ type builderConfig struct {
|
||||
func newBuilderQuery[T any](
|
||||
logger *slog.Logger,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
orgID valuer.UUID,
|
||||
stmtBuilder qbtypes.StatementBuilder[T],
|
||||
spec qbtypes.QueryBuilderQuery[T],
|
||||
tr qbtypes.TimeRange,
|
||||
@@ -58,7 +55,6 @@ func newBuilderQuery[T any](
|
||||
return &builderQuery[T]{
|
||||
logger: logger,
|
||||
telemetryStore: telemetryStore,
|
||||
orgID: orgID,
|
||||
stmtBuilder: stmtBuilder,
|
||||
spec: spec,
|
||||
variables: variables,
|
||||
@@ -218,7 +214,7 @@ func (q *builderQuery[T]) isWindowList() bool {
|
||||
|
||||
// Statement renders the SQL without executing it, for the preview path.
|
||||
func (q *builderQuery[T]) Statement(ctx context.Context) (*qbtypes.Statement, error) {
|
||||
return q.stmtBuilder.Build(ctx, q.orgID, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
|
||||
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.variables)
|
||||
}
|
||||
|
||||
func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
@@ -242,7 +238,7 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
|
||||
}
|
||||
}
|
||||
|
||||
stmt, err := q.stmtBuilder.Build(ctx, q.orgID, fromMS, toMS, q.kind, q.spec, q.variables)
|
||||
stmt, err := q.stmtBuilder.Build(ctx, fromMS, toMS, q.kind, q.spec, q.variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -495,7 +491,7 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
|
||||
q.spec.Offset = 0
|
||||
q.spec.Limit = need
|
||||
|
||||
stmt, err := q.stmtBuilder.Build(ctx, q.orgID, r.fromNS/1e6, r.toNS/1e6, q.kind, q.spec, q.variables)
|
||||
stmt, err := q.stmtBuilder.Build(ctx, r.fromNS/1e6, r.toNS/1e6, q.kind, q.spec, q.variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -21,11 +21,6 @@ import (
|
||||
|
||||
var (
|
||||
aggRe = regexp.MustCompile(`^__result_(\d+)$`)
|
||||
// keyAliasRe matches the traces statement builder's positional column-alias prefix
|
||||
// `__SELECT_KEY_<n>_` / `__GROUP_BY_KEY_<n>_`, which disambiguates select/group-by
|
||||
// aliases from real table columns in the generated SQL. It is stripped here so the
|
||||
// original field name surfaces as the label / column / raw-data key.
|
||||
keyAliasRe = regexp.MustCompile(`^__(?:SELECT|GROUP_BY)_KEY_\d+_`)
|
||||
// legacyReservedColumnTargetAliases identifies result value from a user
|
||||
// written clickhouse query. The column alias indcate which value is
|
||||
// to be considered as final result (or target).
|
||||
@@ -34,12 +29,6 @@ var (
|
||||
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
)
|
||||
|
||||
// stripKeyAlias removes the __SELECT_KEY_<n>_ / __GROUP_BY_KEY_<n>_ prefix from a result
|
||||
// column name, recovering the field name; unprefixed names are returned unchanged.
|
||||
func stripKeyAlias(name string) string {
|
||||
return keyAliasRe.ReplaceAllString(name, "")
|
||||
}
|
||||
|
||||
// consume reads every row and shapes it into the payload expected for the
|
||||
// given request type.
|
||||
//
|
||||
@@ -137,7 +126,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
)
|
||||
|
||||
for idx, ptr := range slots {
|
||||
name := stripKeyAlias(colNames[idx])
|
||||
name := colNames[idx]
|
||||
|
||||
switch v := ptr.(type) {
|
||||
case *time.Time:
|
||||
@@ -307,7 +296,6 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
|
||||
|
||||
var aggIndex int64
|
||||
for i, name := range colNames {
|
||||
name = stripKeyAlias(name)
|
||||
colType := qbtypes.ColumnTypeGroup
|
||||
// Builder queries aliases aggregation columns as __result_N (always numeric) and wraps group-by keys with toString (always string);
|
||||
// Raw ClickHouse queries may use any aliases.
|
||||
@@ -418,7 +406,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}
|
||||
|
||||
for i, cellPtr := range scan {
|
||||
name := stripKeyAlias(colNames[i])
|
||||
name := colNames[i]
|
||||
|
||||
// de-reference the typed pointer to any
|
||||
val := reflect.ValueOf(cellPtr).Elem().Interface()
|
||||
|
||||
@@ -78,7 +78,7 @@ func (q *querier) QueryRangePreview(
|
||||
skip[name] = true
|
||||
}
|
||||
}
|
||||
providers, buildErrs := q.buildPreviewProviders(orgID, req, dependencyQueries, missingMetricQuerySet, skip)
|
||||
providers, buildErrs := q.buildPreviewProviders(req, dependencyQueries, missingMetricQuerySet, skip)
|
||||
|
||||
// Render each executing query's statement and collect the ClickHouse-bound
|
||||
// analysis work to run concurrently.
|
||||
@@ -192,7 +192,6 @@ func missingMetricNames(env qbtypes.QueryEnvelope) []string {
|
||||
}
|
||||
|
||||
func (q *querier) buildPreviewProviders(
|
||||
orgID valuer.UUID,
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
dependencyQueries map[string]bool,
|
||||
missingMetricQuerySet map[string]bool,
|
||||
@@ -231,7 +230,7 @@ func (q *querier) buildPreviewProviders(
|
||||
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
|
||||
}
|
||||
|
||||
built, _, bErr := q.buildQueries(orgID, &sub, deps, missingMetricQuerySet, event)
|
||||
built, _, bErr := q.buildQueries(&sub, deps, missingMetricQuerySet, event, promqlOptions{})
|
||||
if bErr != nil {
|
||||
errs[name] = bErr
|
||||
continue
|
||||
|
||||
@@ -8,15 +8,19 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
@@ -39,6 +43,13 @@ var quotedMetricOutsideBracesPattern = regexp.MustCompile(`"([^"]+)"\s*\{`)
|
||||
// tryEnhancePromQLExecError attempts to convert a PromQL execution error into
|
||||
// a properly typed error. Returns nil if the error is not a recognized execution error.
|
||||
func tryEnhancePromQLExecError(execErr error) error {
|
||||
// A storage may fail a query with an already-typed error (e.g. the
|
||||
// clickhousev2 series/sample budgets); surface it as-is instead of
|
||||
// flattening it into an internal error.
|
||||
if typed := typedStorageError(execErr); typed != nil {
|
||||
return typed
|
||||
}
|
||||
|
||||
var eqc promql.ErrQueryCanceled
|
||||
var eqt promql.ErrQueryTimeout
|
||||
var es promql.ErrStorage
|
||||
@@ -58,6 +69,30 @@ func tryEnhancePromQLExecError(execErr error) error {
|
||||
}
|
||||
}
|
||||
|
||||
// typedStorageError walks an engine execution error chain looking for a
|
||||
// SigNoz-typed invalid-input error raised by the storage layer (the budget
|
||||
// refusals). Every wrapper level is stepped through by hand: Ast is a bare
|
||||
// type cast, not an unwrap — it misses a typed error behind the engine's
|
||||
// "expanding series: %w" — and promql.ErrStorage has no Unwrap method at
|
||||
// all, so a plain unwrap loop would stop at it.
|
||||
func typedStorageError(execErr error) error {
|
||||
for e := execErr; e != nil; {
|
||||
if errors.Ast(e, errors.TypeInvalidInput) {
|
||||
return e
|
||||
}
|
||||
if es, ok := e.(promql.ErrStorage); ok {
|
||||
e = es.Err
|
||||
continue
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
e = u.Unwrap()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enhancePromQLError adds helpful context to PromQL parse errors,
|
||||
// particularly for UTF-8 syntax migration issues where metric and label
|
||||
// names containing dots need to be quoted.
|
||||
@@ -98,6 +133,24 @@ type promqlQuery struct {
|
||||
tr qbv5.TimeRange
|
||||
requestType qbv5.RequestType
|
||||
vars map[string]qbv5.VariableItem
|
||||
opts promqlOptions
|
||||
}
|
||||
|
||||
// promqlOptions is how a PromQL query relates to the clickhousev2 provider
|
||||
// (see querier.promqlOptions for where the fields come from and why they are
|
||||
// flag-gated). Both providers are nil for a plain request, so a plain
|
||||
// request costs nothing extra.
|
||||
type promqlOptions struct {
|
||||
// shadow, when set, runs the query on this provider after serving and
|
||||
// logs any result difference; the response is never affected.
|
||||
shadow *clickhouseprometheusv2.Provider
|
||||
// shadowSlots is the querier-wide admission for shadow runs, shared by
|
||||
// every query so the bound holds per process.
|
||||
shadowSlots chan struct{}
|
||||
// serve, when set, serves the response from this provider instead of the
|
||||
// default path. Comparison callers fetch the default and the pinned
|
||||
// result as two API calls and diff them.
|
||||
serve *clickhouseprometheusv2.Provider
|
||||
}
|
||||
|
||||
var _ qbv5.Query = (*promqlQuery)(nil)
|
||||
@@ -110,6 +163,7 @@ func newPromqlQuery(
|
||||
tr qbv5.TimeRange,
|
||||
requestType qbv5.RequestType,
|
||||
variables map[string]qbv5.VariableItem,
|
||||
opts promqlOptions,
|
||||
) *promqlQuery {
|
||||
return &promqlQuery{
|
||||
logger: logger,
|
||||
@@ -119,10 +173,20 @@ func newPromqlQuery(
|
||||
tr: tr,
|
||||
requestType: requestType,
|
||||
vars: variables,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
func (q *promqlQuery) Fingerprint() string {
|
||||
// A pinned request must not share cache entries with default serving: a
|
||||
// cached default result would satisfy the pin without running the pinned
|
||||
// provider, and a pinned result would poison normal serving. No
|
||||
// fingerprint means no caching at all — the pin exists to observe a
|
||||
// provider, so a cache in front of it defeats the point.
|
||||
if q.opts.serve != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
query, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
|
||||
if err != nil {
|
||||
q.logger.ErrorContext(context.TODO(), "failed render template variables", slog.String("query", q.query.Query))
|
||||
@@ -248,7 +312,16 @@ func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.Captu
|
||||
start := int64(querybuilder.ToNanoSecs(q.tr.From))
|
||||
end := int64(querybuilder.ToNanoSecs(q.tr.To))
|
||||
|
||||
// Attach the same query traits as Execute so the captured statements
|
||||
// match what the live path would run.
|
||||
if expr, parseErr := q.parser.ParseExpr(rendered); parseErr == nil {
|
||||
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
|
||||
}
|
||||
|
||||
capStorage, recorder := storer.CapturingStorage()
|
||||
if capStorage == nil {
|
||||
return nil, nil
|
||||
}
|
||||
qry, err := q.promEngine.Engine().NewRangeQuery(
|
||||
ctx,
|
||||
capStorage,
|
||||
@@ -292,6 +365,58 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Attach query traits so the storage can prove step-aligned optimizations
|
||||
// safe (see prometheus.QueryTraits). A parse failure surfaces below via
|
||||
// the engine with the enhanced error message.
|
||||
if expr, parseErr := q.parser.ParseExpr(query); parseErr == nil {
|
||||
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
|
||||
}
|
||||
|
||||
// Accumulate ClickHouse-side scan stats across every storage query this
|
||||
// evaluation issues (engine selectors or the compiled executor): progress
|
||||
// options propagate to each ClickHouse query through the context.
|
||||
var statsMu sync.Mutex
|
||||
var rowsScanned, bytesScanned uint64
|
||||
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(func(p *clickhouse.Progress) {
|
||||
statsMu.Lock()
|
||||
rowsScanned += p.Rows
|
||||
bytesScanned += p.Bytes
|
||||
statsMu.Unlock()
|
||||
}))
|
||||
|
||||
began := time.Now()
|
||||
|
||||
// A pinned provider serves directly from it: comparison callers fetch
|
||||
// the default result and the pinned result as two API calls and diff
|
||||
// them.
|
||||
if q.opts.serve != nil {
|
||||
matrix, err := q.serveFromProvider(ctx, query, start, end)
|
||||
if err != nil {
|
||||
if enhanced := tryEnhancePromQLExecError(err); enhanced != nil {
|
||||
return nil, enhanced
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
}
|
||||
|
||||
// When the serving provider itself is clickhousev2
|
||||
// (prometheus::provider: clickhousev2), serve the way the provider is
|
||||
// designed to serve: transpiled when the shape allows. Without this the
|
||||
// override would silently run the engine path only.
|
||||
if prov, ok := q.promEngine.(*clickhouseprometheusv2.Provider); ok {
|
||||
matrix, served, err := prov.TryExecuteRange(ctx, query, time.Unix(0, start), time.Unix(0, end), q.query.Step.Duration)
|
||||
if err != nil {
|
||||
if enhanced := tryEnhancePromQLExecError(err); enhanced != nil {
|
||||
return nil, enhanced
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if served {
|
||||
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
}
|
||||
}
|
||||
|
||||
qry, err := q.promEngine.Engine().NewRangeQuery(
|
||||
ctx,
|
||||
q.promEngine.Storage(),
|
||||
@@ -327,6 +452,34 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
return nil, errors.WrapInternalf(promErr, errors.CodeInternal, "error getting matrix from promql query %q", query)
|
||||
}
|
||||
|
||||
if q.opts.shadow != nil {
|
||||
// Shadows detach from the request, so without admission a dashboard
|
||||
// burst would stack unbounded ClickHouse work for up to the shadow
|
||||
// timeout — the concurrency pattern behind the original outages.
|
||||
// Non-blocking: at the cap the comparison is skipped, not queued;
|
||||
// a sampled shadow stream is exactly as useful for rollout evidence.
|
||||
select {
|
||||
case q.opts.shadowSlots <- struct{}{}:
|
||||
// The engine pools the result's sample slices on Close; the
|
||||
// shadow comparison needs a stable copy of what was served.
|
||||
served := copyMatrix(matrix)
|
||||
servedIn := time.Since(began)
|
||||
go func() {
|
||||
defer func() { <-q.opts.shadowSlots }()
|
||||
q.runShadowCompare(context.WithoutCancel(ctx), query, start, end, served, servedIn)
|
||||
}()
|
||||
default:
|
||||
q.logger.DebugContext(ctx, "promql shadow skipped: at concurrency cap", slog.String("query", query))
|
||||
}
|
||||
}
|
||||
|
||||
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
|
||||
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned), nil
|
||||
}
|
||||
|
||||
// toResult converts an evaluated matrix into the v5 result shape, attaching
|
||||
// the ClickHouse scan stats accumulated during evaluation.
|
||||
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
|
||||
excludeLabel := func(labelName string) bool {
|
||||
if labelName == "__name__" {
|
||||
return false
|
||||
@@ -359,7 +512,13 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
series = append(series, &s)
|
||||
}
|
||||
|
||||
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
|
||||
statsMu.Lock()
|
||||
stats := qbv5.ExecStats{
|
||||
RowsScanned: *rowsScanned,
|
||||
BytesScanned: *bytesScanned,
|
||||
DurationMS: uint64(time.Since(began).Milliseconds()),
|
||||
}
|
||||
statsMu.Unlock()
|
||||
|
||||
return &qbv5.Result{
|
||||
Type: q.requestType,
|
||||
@@ -372,6 +531,6 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
},
|
||||
},
|
||||
Warnings: warnings,
|
||||
// TODO: map promql stats?
|
||||
}, nil
|
||||
Stats: stats,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -440,3 +442,35 @@ func TestQuotedMetricOutsideBracesPattern(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// wrappedErr stands in for the engine's fmt-based "expanding series: %w"
|
||||
// wrapper: an ordinary error with an Unwrap chain that is not a SigNoz base
|
||||
// error itself.
|
||||
type wrappedErr struct{ inner error }
|
||||
|
||||
func (w wrappedErr) Error() string { return "expanding series: " + w.inner.Error() }
|
||||
func (w wrappedErr) Unwrap() error { return w.inner }
|
||||
|
||||
// A typed budget refusal must survive the engine's wrapping and reach the
|
||||
// API as invalid input; flattened to internal it becomes a 500 the user
|
||||
// cannot act on — the exact failure this error type exists to prevent.
|
||||
func TestTypedStorageError_SeesThroughEngineWrappers(t *testing.T) {
|
||||
budget := errors.NewInvalidInputf(errors.CodeInvalidInput, "promql selector matched more than 500000 series")
|
||||
|
||||
assert.NotNil(t, typedStorageError(wrappedErr{inner: budget}), "typed error behind an Unwrap wrapper")
|
||||
assert.NotNil(t, typedStorageError(promql.ErrStorage{Err: wrappedErr{inner: budget}}), "typed error behind ErrStorage then a wrapper")
|
||||
assert.NotNil(t, typedStorageError(wrappedErr{inner: promql.ErrStorage{Err: budget}}), "typed error behind a wrapper then ErrStorage")
|
||||
assert.Nil(t, typedStorageError(wrappedErr{inner: errors.NewInternalf(errors.CodeInternal, "boom")}), "internal errors stay internal")
|
||||
}
|
||||
|
||||
// A pinned request must not share cache entries with default serving: a
|
||||
// cached default result would satisfy the pin without running the pinned
|
||||
// provider.
|
||||
func TestFingerprint_PinnedProviderBypassesCache(t *testing.T) {
|
||||
q := &promqlQuery{
|
||||
logger: slog.Default(),
|
||||
query: qbv5.PromQuery{Query: "up"},
|
||||
opts: promqlOptions{serve: &clickhouseprometheusv2.Provider{}},
|
||||
}
|
||||
assert.Empty(t, q.Fingerprint())
|
||||
}
|
||||
|
||||
195
pkg/querier/promql_shadow.go
Normal file
195
pkg/querier/promql_shadow.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
)
|
||||
|
||||
// shadowTimeout bounds a shadow evaluation; a shadow run must never outlive
|
||||
// the request by much or pile up.
|
||||
const shadowTimeout = 2 * time.Minute
|
||||
|
||||
// runShadowCompare executes the query on the clickhousev2 provider exactly
|
||||
// as it would serve (transpiled when the shape allows, engine over the v2
|
||||
// querier otherwise), compares against the served result and logs the
|
||||
// outcome. Serving is never affected: this runs after the response, off the
|
||||
// request context, and only logs. The mismatch and failure logs are the
|
||||
// rollout evidence — serving cuts over to v2 only after they stay clean.
|
||||
func (q *promqlQuery) runShadowCompare(ctx context.Context, query string, startNs, endNs int64, served promql.Matrix, servedIn time.Duration) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
q.logger.ErrorContext(ctx, "promql shadow comparison panicked", slog.Any("panic", r), slog.String("query", query))
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, shadowTimeout)
|
||||
defer cancel()
|
||||
|
||||
// The request context carries the served response's scan-stats progress
|
||||
// callback; without replacing it the shadow's ClickHouse progress would
|
||||
// race into the served stats. The response itself was already sent.
|
||||
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(func(*clickhouse.Progress) {}))
|
||||
|
||||
if expr, parseErr := q.parser.ParseExpr(query); parseErr == nil {
|
||||
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
|
||||
}
|
||||
|
||||
start, end := time.Unix(0, startNs), time.Unix(0, endNs)
|
||||
began := time.Now()
|
||||
shadow, transpiled, err := executeOnProvider(ctx, q.opts.shadow, query, start, end, q.query.Step.Duration)
|
||||
shadowIn := time.Since(began)
|
||||
|
||||
logAttrs := []any{
|
||||
slog.String("query", query),
|
||||
slog.Int64("start_ms", startNs/int64(time.Millisecond)),
|
||||
slog.Int64("end_ms", endNs/int64(time.Millisecond)),
|
||||
slog.Duration("step", q.query.Step.Duration),
|
||||
slog.Bool("transpiled", transpiled),
|
||||
slog.Duration("served_in", servedIn),
|
||||
slog.Duration("shadow_in", shadowIn),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// A shadow failure would be a serving failure after rollout; surface
|
||||
// it at the same level as a result mismatch.
|
||||
q.logger.WarnContext(ctx, "promql shadow execution failed", append(logAttrs, slog.Any("error", err))...)
|
||||
return
|
||||
}
|
||||
|
||||
servedNorm := normalizeShadowMatrix(served)
|
||||
shadowNorm := normalizeShadowMatrix(shadow)
|
||||
if diff := diffShadowMatrices(servedNorm, shadowNorm); diff != "" {
|
||||
q.logger.WarnContext(ctx, "promql shadow comparison mismatch", append(logAttrs,
|
||||
slog.String("diff", diff),
|
||||
slog.Int("served_series", len(servedNorm)),
|
||||
slog.Int("shadow_series", len(shadowNorm)),
|
||||
)...)
|
||||
return
|
||||
}
|
||||
// Matches log the timings: served_in vs shadow_in across the fleet is
|
||||
// the perf evidence for the cutover, gathered for free.
|
||||
q.logger.DebugContext(ctx, "promql shadow comparison matched", logAttrs...)
|
||||
}
|
||||
|
||||
// serveFromProvider evaluates the query the way the pinned provider would
|
||||
// serve it.
|
||||
func (q *promqlQuery) serveFromProvider(ctx context.Context, query string, startNs, endNs int64) (promql.Matrix, error) {
|
||||
matrix, _, err := executeOnProvider(ctx, q.opts.serve, query, time.Unix(0, startNs), time.Unix(0, endNs), q.query.Step.Duration)
|
||||
return matrix, err
|
||||
}
|
||||
|
||||
// executeOnProvider evaluates the query the way the provider would serve it:
|
||||
// transpiled in ClickHouse when the shape allows, the engine over the
|
||||
// provider's storage otherwise. The returned matrix is an owned copy.
|
||||
func executeOnProvider(ctx context.Context, prov *clickhouseprometheusv2.Provider, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
|
||||
matrix, ok, err := prov.TryExecuteRange(ctx, query, start, end, step)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if ok {
|
||||
return matrix, true, nil
|
||||
}
|
||||
|
||||
qry, err := prov.Engine().NewRangeQuery(ctx, prov.Storage(), nil, query, start, end, step)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer qry.Close()
|
||||
|
||||
res := qry.Exec(ctx)
|
||||
if res.Err != nil {
|
||||
return nil, false, res.Err
|
||||
}
|
||||
matrix, err = res.Matrix()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// Close returns the result's sample slices to the engine pool.
|
||||
return copyMatrix(matrix), false, nil
|
||||
}
|
||||
|
||||
func copyMatrix(matrix promql.Matrix) promql.Matrix {
|
||||
out := make(promql.Matrix, 0, len(matrix))
|
||||
for _, s := range matrix {
|
||||
floats := make([]promql.FPoint, len(s.Floats))
|
||||
copy(floats, s.Floats)
|
||||
out = append(out, promql.Series{Metric: s.Metric.Copy(), Floats: floats})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeShadowMatrix strips the labels the two providers legitimately
|
||||
// disagree on — v1 injects a synthetic fingerprint label and leaks
|
||||
// empty-valued labels from the stored attribute JSON, both removed from API
|
||||
// responses anyway — and sorts by label set.
|
||||
func normalizeShadowMatrix(matrix promql.Matrix) promql.Matrix {
|
||||
out := make(promql.Matrix, 0, len(matrix))
|
||||
for _, s := range matrix {
|
||||
builder := labels.NewBuilder(s.Metric)
|
||||
builder.Del(prometheus.FingerprintAsPromLabelName)
|
||||
s.Metric.Range(func(l labels.Label) {
|
||||
if l.Value == "" {
|
||||
builder.Del(l.Name)
|
||||
}
|
||||
})
|
||||
out = append(out, promql.Series{Metric: builder.Labels(), Floats: s.Floats})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
|
||||
return out
|
||||
}
|
||||
|
||||
// diffShadowMatrices returns a description of the first difference, or "".
|
||||
// Values compare with relative tolerance: spatial aggregations accumulate
|
||||
// floats in storage order, which differs between the providers in the last
|
||||
// ULP.
|
||||
func diffShadowMatrices(served, shadow promql.Matrix) string {
|
||||
const relTol = 1e-9
|
||||
if len(served) != len(shadow) {
|
||||
return fmt.Sprintf("series count: served=%d shadow=%d", len(served), len(shadow))
|
||||
}
|
||||
for i := range served {
|
||||
if labels.Compare(served[i].Metric, shadow[i].Metric) != 0 {
|
||||
return fmt.Sprintf("series %d labels: served=%s shadow=%s", i, served[i].Metric, shadow[i].Metric)
|
||||
}
|
||||
if len(served[i].Floats) != len(shadow[i].Floats) {
|
||||
return fmt.Sprintf("series %s points: served=%d shadow=%d", served[i].Metric, len(served[i].Floats), len(shadow[i].Floats))
|
||||
}
|
||||
for j := range served[i].Floats {
|
||||
a, b := served[i].Floats[j], shadow[i].Floats[j]
|
||||
if a.T != b.T {
|
||||
return fmt.Sprintf("series %s point %d ts: served=%d shadow=%d", served[i].Metric, j, a.T, b.T)
|
||||
}
|
||||
// NaN and infinities first: NaN != NaN and Inf-Inf arithmetic
|
||||
// would otherwise make one-sided NaN and Inf-vs-finite compare
|
||||
// as equal (NaN > x and Inf > Inf are both false).
|
||||
if math.IsNaN(a.F) || math.IsNaN(b.F) {
|
||||
if math.IsNaN(a.F) != math.IsNaN(b.F) {
|
||||
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if math.IsInf(a.F, 0) || math.IsInf(b.F, 0) {
|
||||
if a.F != b.F {
|
||||
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
|
||||
}
|
||||
continue
|
||||
}
|
||||
diff := math.Abs(a.F - b.F)
|
||||
scale := math.Max(math.Abs(a.F), math.Abs(b.F))
|
||||
if diff > relTol*math.Max(scale, 1e-300) && diff > 1e-12 {
|
||||
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
66
pkg/querier/promql_shadow_test.go
Normal file
66
pkg/querier/promql_shadow_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeShadowMatrix(t *testing.T) {
|
||||
matrix := promql.Matrix{
|
||||
{
|
||||
Metric: labels.FromStrings("__name__", "up", "fingerprint", "42", "empty", "", "job", "api"),
|
||||
Floats: []promql.FPoint{{T: 1000, F: 1}},
|
||||
},
|
||||
{
|
||||
Metric: labels.FromStrings("a", "1"),
|
||||
Floats: []promql.FPoint{{T: 1000, F: 2}},
|
||||
},
|
||||
}
|
||||
norm := normalizeShadowMatrix(matrix)
|
||||
// sorted by labels; fingerprint and empty-valued labels stripped
|
||||
assert.Equal(t, labels.FromStrings("__name__", "up", "job", "api"), norm[0].Metric)
|
||||
assert.Equal(t, labels.FromStrings("a", "1"), norm[1].Metric)
|
||||
}
|
||||
|
||||
func TestDiffShadowMatrices(t *testing.T) {
|
||||
series := func(v float64) promql.Matrix {
|
||||
return promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 1000, F: v}}}}
|
||||
}
|
||||
|
||||
assert.Empty(t, diffShadowMatrices(series(1.5), series(1.5)))
|
||||
// last-ULP differences from storage-order float accumulation are expected
|
||||
assert.Empty(t, diffShadowMatrices(series(0.08888888888888889), series(0.08888888888888888)))
|
||||
assert.Empty(t, diffShadowMatrices(series(math.NaN()), series(math.NaN())))
|
||||
|
||||
assert.Contains(t, diffShadowMatrices(series(1.5), series(1.6)), "value")
|
||||
assert.Contains(t, diffShadowMatrices(series(1.5), promql.Matrix{}), "series count")
|
||||
assert.Contains(t, diffShadowMatrices(
|
||||
series(1.5),
|
||||
promql.Matrix{{Metric: labels.FromStrings("a", "2"), Floats: []promql.FPoint{{T: 1000, F: 1.5}}}},
|
||||
), "labels")
|
||||
assert.Contains(t, diffShadowMatrices(
|
||||
series(1.5),
|
||||
promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 2000, F: 1.5}}}},
|
||||
), "ts")
|
||||
}
|
||||
|
||||
// One-sided NaN makes every float comparison false, and Inf-Inf arithmetic
|
||||
// yields Inf > Inf == false; without explicit handling both divergences log
|
||||
// as matched — a shadow comparator that cannot see them would green-light a
|
||||
// broken rollout.
|
||||
func TestDiffShadowMatrices_SpecialFloats(t *testing.T) {
|
||||
point := func(v float64) promql.Matrix {
|
||||
return promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 1000, F: v}}}}
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, diffShadowMatrices(point(math.NaN()), point(1.5)), "one-sided NaN must diff")
|
||||
assert.NotEmpty(t, diffShadowMatrices(point(1.5), point(math.NaN())), "one-sided NaN must diff either way")
|
||||
assert.NotEmpty(t, diffShadowMatrices(point(math.Inf(1)), point(1.5)), "Inf vs finite must diff")
|
||||
assert.NotEmpty(t, diffShadowMatrices(point(math.Inf(1)), point(math.Inf(-1))), "opposite infinities must diff")
|
||||
assert.Empty(t, diffShadowMatrices(point(math.Inf(1)), point(math.Inf(1))), "equal infinities match")
|
||||
assert.Empty(t, diffShadowMatrices(point(math.NaN()), point(math.NaN())), "both NaN match")
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
@@ -36,11 +38,19 @@ var (
|
||||
)
|
||||
|
||||
type querier struct {
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
logger *slog.Logger
|
||||
fl flagger.Flagger
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
promEngine prometheus.Prometheus
|
||||
// promV2 is the clickhousev2 prometheus provider, wired only when the
|
||||
// serving provider is the default one (nil otherwise). It reads the same
|
||||
// ClickHouse data through a different implementation; PromQL queries
|
||||
// shadow-compare against it behind the use_prometheus_clickhouse_v2 flag
|
||||
// and can be pinned to it for a response (see promqlOptions). It never
|
||||
// serves by default — that cutover happens only after the shadow logs
|
||||
// stay clean.
|
||||
promV2 *clickhouseprometheusv2.Provider
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
|
||||
@@ -51,8 +61,16 @@ type querier struct {
|
||||
liveDataRefresh time.Duration
|
||||
builderConfig builderConfig
|
||||
maxConcurrentQueries int
|
||||
// shadowSlots bounds concurrent shadow comparisons per process; shadows
|
||||
// detach from their requests, so nothing else limits how many pile up.
|
||||
shadowSlots chan struct{}
|
||||
}
|
||||
|
||||
// maxConcurrentShadows is deliberately small: a shadow is a full extra
|
||||
// ClickHouse evaluation, and a sampled stream of comparisons is exactly as
|
||||
// useful for rollout evidence as an exhaustive one under load.
|
||||
const maxConcurrentShadows = 8
|
||||
|
||||
var _ Querier = (*querier)(nil)
|
||||
|
||||
func New(
|
||||
@@ -60,6 +78,7 @@ func New(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
metadataStore telemetrytypes.MetadataStore,
|
||||
promEngine prometheus.Prometheus,
|
||||
promV2 *clickhouseprometheusv2.Provider,
|
||||
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
|
||||
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
|
||||
@@ -81,6 +100,7 @@ func New(
|
||||
telemetryStore: telemetryStore,
|
||||
metadataStore: metadataStore,
|
||||
promEngine: promEngine,
|
||||
promV2: promV2,
|
||||
traceStmtBuilder: traceStmtBuilder,
|
||||
logStmtBuilder: logStmtBuilder,
|
||||
auditStmtBuilder: auditStmtBuilder,
|
||||
@@ -93,6 +113,7 @@ func New(
|
||||
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
|
||||
},
|
||||
maxConcurrentQueries: maxConcurrentQueries,
|
||||
shadowSlots: make(chan struct{}, maxConcurrentShadows),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +153,11 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
missingMetricQuerySet[name] = true
|
||||
}
|
||||
|
||||
queries, steps, err := q.buildQueries(orgID, req, dependencyQueries, missingMetricQuerySet, event)
|
||||
promqlOpts, err := q.promqlOptions(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queries, steps, err := q.buildQueries(req, dependencyQueries, missingMetricQuerySet, event, promqlOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -175,12 +200,40 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
|
||||
return qbResp, qbErr
|
||||
}
|
||||
|
||||
// promqlOptions derives the PromQL execution options for a request. With the
|
||||
// org's use_prometheus_clickhouse_v2 flag on, queries are shadow-compared
|
||||
// against the clickhousev2 provider (serving unaffected, diffs logged; see
|
||||
// promql_shadow.go). The X-SigNoz-PromQL-Provider header may instead pin the
|
||||
// response to that provider — integration tests and support fetch both
|
||||
// results for comparison — so it is deliberately flag-gated too: without the
|
||||
// gate the header would be an unaudited switch onto a provider still under
|
||||
// validation.
|
||||
func (q *querier) promqlOptions(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (promqlOptions, error) {
|
||||
enabled := q.fl.BooleanOrEmpty(ctx, flagger.FeatureUsePrometheusClickhouseV2, featuretypes.NewFlaggerEvaluationContext(orgID))
|
||||
if req.PromQLProvider == "" {
|
||||
if enabled && q.promV2 != nil {
|
||||
return promqlOptions{shadow: q.promV2, shadowSlots: q.shadowSlots}, nil
|
||||
}
|
||||
return promqlOptions{}, nil
|
||||
}
|
||||
if req.PromQLProvider != prometheus.ProviderClickhouseV2 {
|
||||
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown promql provider %q", req.PromQLProvider)
|
||||
}
|
||||
if !enabled {
|
||||
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "promql provider %q requires the use_prometheus_clickhouse_v2 flag", req.PromQLProvider)
|
||||
}
|
||||
if q.promV2 == nil {
|
||||
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "promql provider %q is not available", req.PromQLProvider)
|
||||
}
|
||||
return promqlOptions{serve: q.promV2}, nil
|
||||
}
|
||||
|
||||
func (q *querier) buildQueries(
|
||||
orgID valuer.UUID,
|
||||
req *qbtypes.QueryRangeRequest,
|
||||
dependencyQueries map[string]bool,
|
||||
missingMetricQuerySet map[string]bool,
|
||||
event *qbtypes.QBEvent,
|
||||
promqlOpts promqlOptions,
|
||||
) (map[string]qbtypes.Query, map[string]qbtypes.Step, error) {
|
||||
|
||||
tmplVars := req.Variables
|
||||
@@ -205,7 +258,7 @@ func (q *querier) buildQueries(
|
||||
if !ok {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
|
||||
}
|
||||
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
|
||||
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars, promqlOpts)
|
||||
queries[promQuery.Name] = promqlQuery
|
||||
steps[promQuery.Name] = promQuery.Step
|
||||
case qbtypes.QueryTypeClickHouseSQL:
|
||||
@@ -222,7 +275,6 @@ func (q *querier) buildQueries(
|
||||
}
|
||||
toq := &traceOperatorQuery{
|
||||
telemetryStore: q.telemetryStore,
|
||||
orgID: orgID,
|
||||
stmtBuilder: q.traceOperatorStmtBuilder,
|
||||
spec: traceOpQuery,
|
||||
compositeQuery: &req.CompositeQuery,
|
||||
@@ -237,7 +289,7 @@ func (q *querier) buildQueries(
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
|
||||
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, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
@@ -247,7 +299,7 @@ func (q *querier) buildQueries(
|
||||
if spec.Source == telemetrytypes.SourceAudit {
|
||||
stmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
|
||||
queries[spec.Name] = bq
|
||||
steps[spec.Name] = spec.StepInterval
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
@@ -264,9 +316,9 @@ func (q *querier) buildQueries(
|
||||
|
||||
if spec.Source == telemetrytypes.SourceMeter {
|
||||
event.Source = telemetrytypes.SourceMeter.StringValue()
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
} else {
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
bq = newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
|
||||
}
|
||||
|
||||
queries[spec.Name] = bq
|
||||
@@ -537,7 +589,7 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
|
||||
if spec.Source == telemetrytypes.SourceAudit {
|
||||
liveTailStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, liveTailStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
|
||||
bq := newBuilderQuery(q.logger, q.telemetryStore, liveTailStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
|
||||
"id": {
|
||||
Value: updatedLogID,
|
||||
},
|
||||
@@ -782,7 +834,7 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
defer func() { <-sem }()
|
||||
|
||||
// Create a new query with the missing time range
|
||||
rangedQuery := q.createRangedQuery(orgID, query, *tr)
|
||||
rangedQuery := q.createRangedQuery(query, *tr)
|
||||
if rangedQuery == nil {
|
||||
errs[idx] = errors.NewInternalf(errors.CodeInternal, "failed to create ranged query for range %d-%d", tr.From, tr.To)
|
||||
return
|
||||
@@ -843,12 +895,12 @@ func (q *querier) executeWithCache(ctx context.Context, orgID valuer.UUID, query
|
||||
}
|
||||
|
||||
// createRangedQuery creates a copy of the query with a different time range.
|
||||
func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
|
||||
func (q *querier) createRangedQuery(originalQuery qbtypes.Query, timeRange qbtypes.TimeRange) qbtypes.Query {
|
||||
// this is called in a goroutine, so we create a copy of the query to avoid race conditions
|
||||
switch qt := originalQuery.(type) {
|
||||
case *promqlQuery:
|
||||
queryCopy := qt.query.Copy()
|
||||
return newPromqlQuery(q.logger, q.promEngine, queryCopy, timeRange, qt.requestType, qt.vars)
|
||||
return newPromqlQuery(q.logger, qt.promEngine, queryCopy, timeRange, qt.requestType, qt.vars, qt.opts)
|
||||
|
||||
case *chSQLQuery:
|
||||
queryCopy := qt.query.Copy()
|
||||
@@ -860,7 +912,7 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.traceStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
|
||||
case *builderQuery[qbtypes.LogAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
@@ -870,21 +922,20 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
|
||||
if qt.spec.Source == telemetrytypes.SourceAudit {
|
||||
shiftStmtBuilder = q.auditStmtBuilder
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
|
||||
|
||||
case *builderQuery[qbtypes.MetricAggregation]:
|
||||
specCopy := qt.spec.Copy()
|
||||
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
|
||||
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
|
||||
if qt.spec.Source == telemetrytypes.SourceMeter {
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
}
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
return newBuilderQuery(q.logger, q.telemetryStore, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
|
||||
case *traceOperatorQuery:
|
||||
specCopy := qt.spec.Copy()
|
||||
return &traceOperatorQuery{
|
||||
telemetryStore: q.telemetryStore,
|
||||
orgID: qt.orgID,
|
||||
stmtBuilder: q.traceOperatorStmtBuilder,
|
||||
spec: specCopy,
|
||||
fromMS: uint64(timeRange.From),
|
||||
|
||||
@@ -30,7 +30,7 @@ func (m *queryMatcherAny) Match(string, string) error { return nil }
|
||||
// and returns a fixed query string so the mock ClickHouse can match it.
|
||||
type mockMetricStmtBuilder struct{}
|
||||
|
||||
func (m *mockMetricStmtBuilder) Build(_ context.Context, _ valuer.UUID, _, _ uint64, _ qbtypes.RequestType, _ qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
|
||||
func (m *mockMetricStmtBuilder) Build(_ context.Context, _, _ uint64, _ qbtypes.RequestType, _ qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation], _ map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
|
||||
return &qbtypes.Statement{
|
||||
Query: "SELECT ts, value FROM signoz_metrics",
|
||||
Args: nil,
|
||||
@@ -48,6 +48,7 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
nil, // telemetryStore
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
@@ -120,6 +121,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
telemetryStore,
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryaudit"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
func NewFactory(
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
promV2 *clickhouseprometheusv2.Provider,
|
||||
cache cache.Cache,
|
||||
flagger flagger.Flagger,
|
||||
) factory.ProviderFactory[querier.Querier, querier.Config] {
|
||||
@@ -32,7 +34,7 @@ func NewFactory(
|
||||
settings factory.ProviderSettings,
|
||||
cfg querier.Config,
|
||||
) (querier.Querier, error) {
|
||||
return newProvider(ctx, settings, cfg, telemetryStore, prometheus, cache, flagger)
|
||||
return newProvider(ctx, settings, cfg, telemetryStore, prometheus, promV2, cache, flagger)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -43,6 +45,7 @@ func newProvider(
|
||||
cfg querier.Config,
|
||||
telemetryStore telemetrystore.TelemetryStore,
|
||||
prometheus prometheus.Prometheus,
|
||||
promV2 *clickhouseprometheusv2.Provider,
|
||||
cache cache.Cache,
|
||||
flagger flagger.Flagger,
|
||||
) (querier.Querier, error) {
|
||||
@@ -184,6 +187,7 @@ func newProvider(
|
||||
telemetryStore,
|
||||
telemetryMetadataStore,
|
||||
prometheus,
|
||||
promV2,
|
||||
traceStmtBuilder,
|
||||
logStmtBuilder,
|
||||
auditStmtBuilder,
|
||||
|
||||
@@ -10,12 +10,10 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type traceOperatorQuery struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
orgID valuer.UUID
|
||||
stmtBuilder qbtypes.TraceOperatorStatementBuilder
|
||||
spec qbtypes.QueryBuilderTraceOperator
|
||||
compositeQuery *qbtypes.CompositeQuery
|
||||
@@ -37,13 +35,12 @@ func (q *traceOperatorQuery) Window() (uint64, uint64) {
|
||||
|
||||
// Statement renders the SQL without executing it, for the preview path.
|
||||
func (q *traceOperatorQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
|
||||
return q.stmtBuilder.Build(ctx, q.orgID, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
|
||||
return q.stmtBuilder.Build(ctx, q.fromMS, q.toMS, q.kind, q.spec, q.compositeQuery)
|
||||
}
|
||||
|
||||
func (q *traceOperatorQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
stmt, err := q.stmtBuilder.Build(
|
||||
ctx,
|
||||
q.orgID,
|
||||
q.fromMS,
|
||||
q.toMS,
|
||||
q.kind,
|
||||
|
||||
@@ -402,6 +402,7 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// TODO(Tushar): thread orgID here to evaluate correctly
|
||||
if pc.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgId)) {
|
||||
// add default normalize pipeline at the beginning, only for sending to collector
|
||||
enrichedPipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, enrichedPipelines...)
|
||||
|
||||
@@ -105,7 +105,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
|
||||
}
|
||||
|
||||
// Create querier with test values
|
||||
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, cache, flagger)
|
||||
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, cache, flagger)
|
||||
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
telemetryStore,
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
@@ -102,6 +103,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
telemetryStore,
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
nil, // traceStmtBuilder
|
||||
logStmtBuilder, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
@@ -151,6 +153,7 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
telemetryStore,
|
||||
metadataStore,
|
||||
nil, // prometheus
|
||||
nil, // promV2
|
||||
traceStmtBuilder, // traceStmtBuilder
|
||||
nil, // logStmtBuilder
|
||||
nil, // auditStmtBuilder
|
||||
|
||||
@@ -53,7 +53,6 @@ func NewAggExprRewriter(
|
||||
// and the args if the parametric aggregation function is used.
|
||||
func (r *aggExprRewriter) Rewrite(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
expr string,
|
||||
@@ -84,7 +83,6 @@ func (r *aggExprRewriter) Rewrite(
|
||||
|
||||
visitor := newExprVisitor(
|
||||
ctx,
|
||||
orgID,
|
||||
startNs,
|
||||
endNs,
|
||||
r.logger,
|
||||
@@ -109,7 +107,6 @@ func (r *aggExprRewriter) Rewrite(
|
||||
// RewriteMulti rewrites a slice of expressions.
|
||||
func (r *aggExprRewriter) RewriteMulti(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
exprs []string,
|
||||
@@ -120,7 +117,7 @@ func (r *aggExprRewriter) RewriteMulti(
|
||||
var errs []error
|
||||
var chArgsList [][]any
|
||||
for i, e := range exprs {
|
||||
w, chArgs, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
|
||||
w, chArgs, err := r.Rewrite(ctx, startNs, endNs, e, rateInterval, keys)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
out[i] = e
|
||||
@@ -138,7 +135,6 @@ func (r *aggExprRewriter) RewriteMulti(
|
||||
// exprVisitor walks FunctionExpr nodes and applies the mappers.
|
||||
type exprVisitor struct {
|
||||
ctx context.Context
|
||||
orgID valuer.UUID
|
||||
startNs uint64
|
||||
endNs uint64
|
||||
chparser.DefaultASTVisitor
|
||||
@@ -156,7 +152,6 @@ type exprVisitor struct {
|
||||
|
||||
func newExprVisitor(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
logger *slog.Logger,
|
||||
@@ -169,7 +164,6 @@ func newExprVisitor(
|
||||
) *exprVisitor {
|
||||
return &exprVisitor{
|
||||
ctx: ctx,
|
||||
orgID: orgID,
|
||||
startNs: startNs,
|
||||
endNs: endNs,
|
||||
logger: logger,
|
||||
@@ -212,7 +206,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
dataType = telemetrytypes.FieldDataTypeFloat64
|
||||
}
|
||||
|
||||
bodyJSONEnabled := v.flagger.BooleanOrEmpty(v.ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(v.orgID))
|
||||
bodyJSONEnabled := v.flagger.BooleanOrEmpty(v.ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
|
||||
// Handle *If functions with predicate + values
|
||||
if aggFunc.FuncCombinator {
|
||||
@@ -222,7 +216,6 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
origPred,
|
||||
FilterExprVisitorOpts{
|
||||
Context: v.ctx,
|
||||
OrgID: v.orgID,
|
||||
Logger: v.logger,
|
||||
FieldKeys: v.fieldKeys,
|
||||
FieldMapper: v.fieldMapper,
|
||||
@@ -254,7 +247,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
origVal := args[i].String()
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(origVal)
|
||||
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.orgID, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey, bodyJSONEnabled)
|
||||
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey, bodyJSONEnabled)
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to get table field name for %q", origVal)
|
||||
}
|
||||
@@ -272,7 +265,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
for i, arg := range args {
|
||||
orig := arg.String()
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(orig)
|
||||
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.orgID, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey, bodyJSONEnabled)
|
||||
expr, exprArgs, err := CollisionHandledFinalExpr(v.ctx, v.startNs, v.endNs, &fieldKey, v.fieldMapper, v.conditionBuilder, v.fieldKeys, dataType, v.jsonKeyToKey, bodyJSONEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,14 +13,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
func CollisionHandledFinalExpr(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
field *telemetrytypes.TelemetryFieldKey,
|
||||
@@ -49,7 +47,7 @@ func CollisionHandledFinalExpr(
|
||||
|
||||
addCondition := func(key *telemetrytypes.TelemetryFieldKey) error {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := cb.ConditionFor(ctx, orgID, startNs, endNs, key, []*telemetrytypes.TelemetryFieldKey{key}, qbtypes.FilterOperatorExists, nil, sb)
|
||||
conds, _, err := cb.ConditionFor(ctx, startNs, endNs, key, []*telemetrytypes.TelemetryFieldKey{key}, qbtypes.FilterOperatorExists, nil, sb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +60,7 @@ func CollisionHandledFinalExpr(
|
||||
return nil
|
||||
}
|
||||
|
||||
fieldExpression, fieldForErr := fm.FieldFor(ctx, orgID, startNs, endNs, field)
|
||||
fieldExpression, fieldForErr := fm.FieldFor(ctx, startNs, endNs, field)
|
||||
if errors.Is(fieldForErr, qbtypes.ErrColumnNotFound) {
|
||||
// the key didn't have the right context to be added to the query
|
||||
// we try to use the context we know of
|
||||
@@ -78,23 +76,23 @@ func CollisionHandledFinalExpr(
|
||||
}
|
||||
|
||||
if len(keysForField) == 0 {
|
||||
// No metadata match: let the field mapper synthesize type-variant keys.
|
||||
// keys were already checked above, so pass nil here.
|
||||
keysForField = fm.CandidateKeys(ctx, orgID, field, nil, nil)
|
||||
}
|
||||
if len(keysForField) == 0 {
|
||||
// The mapper can't synthesize (e.g. metrics): fall back to the typo suggestion.
|
||||
// - the context is not provided
|
||||
// - there are not keys for the field
|
||||
// - it is not a static field
|
||||
// - the next best thing to do is see if there is a typo
|
||||
// and suggest a correction
|
||||
wrappedErr := errors.WithSuggestiveAdditionalf(fieldForErr, errors.NewSuggestionsOnLevenshteinDistance(field.Name, errors.NounKeys, maps.Keys(keys)), "field `%s` not found", field.Name)
|
||||
return "", nil, wrappedErr
|
||||
}
|
||||
for _, key := range keysForField {
|
||||
err := addCondition(key)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
} else {
|
||||
for _, key := range keysForField {
|
||||
err := addCondition(key)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
fieldExpression, _ = fm.FieldFor(ctx, startNs, endNs, key)
|
||||
fieldExpression, _ = DataTypeCollisionHandledFieldName(key, dummyValue, fieldExpression, qbtypes.FilterOperatorUnknown)
|
||||
stmts = append(stmts, fieldExpression)
|
||||
}
|
||||
fieldExpression, _ = fm.FieldFor(ctx, orgID, startNs, endNs, key)
|
||||
fieldExpression, _ = DataTypeCollisionHandledFieldName(key, dummyValue, fieldExpression, qbtypes.FilterOperatorUnknown)
|
||||
stmts = append(stmts, fieldExpression)
|
||||
}
|
||||
} else {
|
||||
err := addCondition(field)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -69,100 +68,6 @@ func NewKeyNotFoundError(name string) error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "key `%s` not found", name).WithUrl(KeyNotFoundDocURL)
|
||||
}
|
||||
|
||||
// NewKeyNotFoundWarning is the warning surfaced when a referenced key is absent from
|
||||
// metadata and the query falls back to synthesized keys.
|
||||
func NewKeyNotFoundWarning(name string) string {
|
||||
return fmt.Sprintf("key `%s` not found in metadata; querying the underlying data directly. If this is unexpected, check the key name for typos.", name)
|
||||
}
|
||||
|
||||
// SynthesizeKeys builds the field keys to query when metadata has no match: a qualified
|
||||
// key is honored as-is; a bare key defaults to attribute context with the data type
|
||||
// inferred from the operand, or fanned out across string/number/bool without one.
|
||||
func SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telemetrytypes.TelemetryFieldKey {
|
||||
base := *field
|
||||
if base.FieldContext == telemetrytypes.FieldContextUnspecified {
|
||||
base.FieldContext = telemetrytypes.FieldContextAttribute
|
||||
}
|
||||
// Resource values are strings; pin the type so operand coercion applies.
|
||||
if base.FieldContext == telemetrytypes.FieldContextResource &&
|
||||
base.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
|
||||
base.FieldDataType = telemetrytypes.FieldDataTypeString
|
||||
}
|
||||
|
||||
// A set data type needs only one synthesized key.
|
||||
if base.FieldDataType != telemetrytypes.FieldDataTypeUnspecified {
|
||||
clone := base
|
||||
return []*telemetrytypes.TelemetryFieldKey{&clone}
|
||||
}
|
||||
|
||||
dataTypes := inferDataTypesFromOperand(value)
|
||||
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(dataTypes))
|
||||
for _, dt := range dataTypes {
|
||||
clone := base
|
||||
clone.FieldDataType = dt
|
||||
keys = append(keys, &clone)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// allVariantDataTypes is the fanout used when no operand pins the type (exists / group by).
|
||||
func allVariantDataTypes() []telemetrytypes.FieldDataType {
|
||||
return []telemetrytypes.FieldDataType{
|
||||
telemetrytypes.FieldDataTypeString,
|
||||
telemetrytypes.FieldDataTypeNumber,
|
||||
telemetrytypes.FieldDataTypeBool,
|
||||
}
|
||||
}
|
||||
|
||||
// inferDataTypesFromOperand maps an operand value to the data type(s) to query. With no
|
||||
// operand or an unrecognized type it fans out across all variants.
|
||||
func inferDataTypesFromOperand(value any) []telemetrytypes.FieldDataType {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return allVariantDataTypes()
|
||||
case string:
|
||||
return []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeString}
|
||||
case bool:
|
||||
return []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeBool}
|
||||
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
|
||||
return []telemetrytypes.FieldDataType{telemetrytypes.FieldDataTypeNumber}
|
||||
case []any:
|
||||
return inferDataTypesFromList(v)
|
||||
default:
|
||||
return allVariantDataTypes()
|
||||
}
|
||||
}
|
||||
|
||||
// inferDataTypesFromList derives the distinct data types present in an `in` list, kept
|
||||
// in string/number/bool order; an empty or all-unknown list fans out.
|
||||
func inferDataTypesFromList(values []any) []telemetrytypes.FieldDataType {
|
||||
var hasString, hasNumber, hasBool bool
|
||||
for _, v := range values {
|
||||
switch v.(type) {
|
||||
case string:
|
||||
hasString = true
|
||||
case bool:
|
||||
hasBool = true
|
||||
case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
|
||||
hasNumber = true
|
||||
}
|
||||
}
|
||||
var out []telemetrytypes.FieldDataType
|
||||
if hasString {
|
||||
out = append(out, telemetrytypes.FieldDataTypeString)
|
||||
}
|
||||
if hasNumber {
|
||||
out = append(out, telemetrytypes.FieldDataTypeNumber)
|
||||
}
|
||||
if hasBool {
|
||||
out = append(out, telemetrytypes.FieldDataTypeBool)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return allVariantDataTypes()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NewFunctionUnsupportedError returns the error for a has/hasAny/hasAll/hasToken operator
|
||||
// on a builder that doesn't support it (logs body only), or nil for other operators.
|
||||
func NewFunctionUnsupportedError(operator qbtypes.FilterOperator) error {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user