mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-29 09:10:40 +01:00
Compare commits
8 Commits
issue_5601
...
nv/source-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
934f5f4ad3 | ||
|
|
bca2370862 | ||
|
|
591837152e | ||
|
|
2c5b4184c4 | ||
|
|
0b69f5f677 | ||
|
|
a28b95da35 | ||
|
|
b4b8a9fda0 | ||
|
|
de59c123e1 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -54,6 +54,7 @@ jobs:
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
@@ -2,6 +2,29 @@ import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
// The wire shape these handlers can actually rely on. The generated
|
||||
// RenderErrorResponseDTO marks code/message/url/errors as required, but the
|
||||
// server omits any of them even on valid errors (e.g. a 400 with just a
|
||||
// message), so a present `error` object is all the guard can guarantee.
|
||||
type ErrorEnvelope = {
|
||||
error: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
url?: string;
|
||||
errors?: { message?: string }[];
|
||||
};
|
||||
};
|
||||
|
||||
function isErrorEnvelope(data: unknown): data is ErrorEnvelope {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'error' in data &&
|
||||
typeof (data as ErrorEnvelope).error === 'object' &&
|
||||
(data as ErrorEnvelope).error !== null
|
||||
);
|
||||
}
|
||||
|
||||
// @deprecated Use convertToApiError instead
|
||||
export function ErrorResponseHandlerForGeneratedAPIs(
|
||||
error: AxiosError<RenderErrorResponseDTO>,
|
||||
@@ -10,15 +33,29 @@ export function ErrorResponseHandlerForGeneratedAPIs(
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (response) {
|
||||
// The body isn't guaranteed to be an error envelope — e.g. a gateway 5xx
|
||||
// with an HTML/empty body during a deploy. Verify the shape before reading
|
||||
// it; otherwise synthesize a consistent error from the status.
|
||||
const data: unknown = response.data;
|
||||
if (isErrorEnvelope(data)) {
|
||||
const { code, message, url, errors } = data.error;
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: {
|
||||
code: code ?? '',
|
||||
message: message ?? '',
|
||||
url: url ?? '',
|
||||
errors: (errors ?? []).map((e) => ({ message: e.message ?? '' })),
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: {
|
||||
code: response.data.error.code,
|
||||
message: response.data.error.message,
|
||||
url: response.data.error.url ?? '',
|
||||
errors: (response.data.error.errors ?? []).map((e) => ({
|
||||
message: e.message ?? '',
|
||||
})),
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: error.message || 'Something went wrong',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -62,9 +99,7 @@ export function convertToApiError(
|
||||
return new APIError({
|
||||
httpStatusCode: response?.status || error.status || 500,
|
||||
error: {
|
||||
code:
|
||||
errorData?.code ||
|
||||
String(response?.status || error.code || 'unknown_error'),
|
||||
code: errorData?.code || 'UPSTREAM_UNAVAILABLE',
|
||||
message:
|
||||
errorData?.message ||
|
||||
response?.statusText ||
|
||||
|
||||
@@ -2,19 +2,38 @@ import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function isErrorV2Resp(data: unknown): data is ErrorV2Resp {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'error' in data &&
|
||||
typeof (data as ErrorV2Resp).error?.code === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
// reference - https://axios-http.com/docs/handling_errors
|
||||
export function ErrorResponseHandlerV2(error: AxiosError<ErrorV2Resp>): never {
|
||||
const { response, request } = error;
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
if (response) {
|
||||
// response.data isn't guaranteed to be a V2 envelope (e.g. a gateway 5xx
|
||||
// with an HTML/empty body during a deploy), so verify the shape first.
|
||||
const data: unknown = response.data;
|
||||
if (isErrorV2Resp(data)) {
|
||||
const { code, message, url, errors } = data.error;
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: { code, message, url, errors },
|
||||
});
|
||||
}
|
||||
throw new APIError({
|
||||
httpStatusCode: response.status || 500,
|
||||
error: {
|
||||
code: response.data.error.code,
|
||||
message: response.data.error.message,
|
||||
url: response.data.error.url,
|
||||
errors: response.data.error.errors,
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: error.message || 'Something went wrong',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
126
frontend/src/api/__tests__/ErrorResponseHandlerV2.test.ts
Normal file
126
frontend/src/api/__tests__/ErrorResponseHandlerV2.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
function asAxiosError(partial: Partial<AxiosError>): AxiosError<ErrorV2Resp> {
|
||||
return partial as AxiosError<ErrorV2Resp>;
|
||||
}
|
||||
|
||||
// ErrorResponseHandlerV2 always throws — capture the APIError so assertions stay
|
||||
// unconditional.
|
||||
function runHandler(error: AxiosError<ErrorV2Resp>): APIError {
|
||||
try {
|
||||
ErrorResponseHandlerV2(error);
|
||||
} catch (thrown) {
|
||||
return thrown as APIError;
|
||||
}
|
||||
throw new Error('expected ErrorResponseHandlerV2 to throw');
|
||||
}
|
||||
|
||||
type ExpectedError = {
|
||||
httpStatusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
errors: { message: string }[];
|
||||
};
|
||||
|
||||
// One row per response shape the handler must normalize. New shapes (with
|
||||
// different bodies) can be added here without a new test block.
|
||||
const cases: {
|
||||
name: string;
|
||||
error: AxiosError<ErrorV2Resp>;
|
||||
expected: ExpectedError;
|
||||
}[] = [
|
||||
{
|
||||
name: 'well-formed V2 error envelope',
|
||||
error: asAxiosError({
|
||||
message: 'Request failed with status code 400',
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: 'bad_request',
|
||||
message: 'Invalid dashboard payload',
|
||||
url: 'https://signoz.io/docs',
|
||||
errors: [{ message: 'name is required' }, { message: 'name too long' }],
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'],
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 400,
|
||||
code: 'bad_request',
|
||||
message: 'Invalid dashboard payload',
|
||||
errors: [{ message: 'name is required' }, { message: 'name too long' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression: during a deployment the gateway returns a 5xx with a
|
||||
// non-envelope body. Reading response.data.error.code used to throw a
|
||||
// TypeError from inside the handler itself. See engineering-pod#5760.
|
||||
name: '5xx with a non-envelope HTML body',
|
||||
error: asAxiosError({
|
||||
message: 'Request failed with status code 503',
|
||||
response: {
|
||||
status: 503,
|
||||
data: '<html><body>503 Service Temporarily Unavailable</body></html>',
|
||||
} as AxiosError['response'],
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 503,
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: 'Request failed with status code 503',
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '5xx with an empty body',
|
||||
error: asAxiosError({
|
||||
message: 'Request failed with status code 502',
|
||||
response: {
|
||||
status: 502,
|
||||
data: undefined,
|
||||
} as AxiosError['response'],
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 502,
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: 'Request failed with status code 502',
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'no response received (network error)',
|
||||
error: asAxiosError({
|
||||
message: 'Network Error',
|
||||
code: 'ERR_NETWORK',
|
||||
name: 'AxiosError',
|
||||
request: {},
|
||||
}),
|
||||
expected: {
|
||||
httpStatusCode: 500,
|
||||
code: 'ERR_NETWORK',
|
||||
message: 'Network Error',
|
||||
errors: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('ErrorResponseHandlerV2', () => {
|
||||
it.each(cases)(
|
||||
'normalizes $name into a consistent APIError',
|
||||
({ error, expected }) => {
|
||||
const apiError = runHandler(error);
|
||||
|
||||
expect(apiError).toBeInstanceOf(APIError);
|
||||
expect(apiError.getHttpStatusCode()).toBe(expected.httpStatusCode);
|
||||
expect(apiError.getErrorCode()).toBe(expected.code);
|
||||
expect(apiError.getErrorMessage()).toBe(expected.message);
|
||||
// The sub-error messages feed several parts of the UI, so assert them.
|
||||
expect(apiError.getErrorDetails().error.errors).toStrictEqual(
|
||||
expected.errors,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -92,52 +92,76 @@
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
display: none;
|
||||
// Stack "Only"/"All" and "Toggle" in a single cell so revealing
|
||||
// one swaps in place instead of shifting the row layout.
|
||||
.value-actions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.only-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-value-section:hover {
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
.only-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
// `display` is animated as a discrete property so the button
|
||||
// stays mounted through its fade-out on exit.
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hovering the label/value area reveals the "Only"/"All" action.
|
||||
.checkbox-value-section:hover .only-btn {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
// Entry animation start state (fires on the display switch).
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
// Hovering the checkbox reveals the "Toggle" action.
|
||||
.check-box:hover ~ .checkbox-value-section .toggle-btn {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.value:hover {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 21px;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,14 @@ function CheckboxValueRow({
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
<div className="value-actions">
|
||||
<Button type="text" className="only-btn">
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
.warning-popover-overlay {
|
||||
--antd-arrow-background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--l2-background);
|
||||
|
||||
// === SECTION: Summary (Top)
|
||||
&__summary-section {
|
||||
@@ -10,16 +15,25 @@
|
||||
|
||||
&__summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&__summary-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
height: var(--spacing-12);
|
||||
}
|
||||
|
||||
&__summary-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactNode, useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Popover, PopoverProps } from 'antd';
|
||||
import ErrorIcon from 'assets/Error';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { BookOpenText, ChevronsDown, TriangleAlert } from '@signozhq/icons';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
@@ -30,16 +31,18 @@ export function WarningContent({ warning }: WarningContentProps): JSX.Element {
|
||||
{/* Summary Header */}
|
||||
<section className="warning-content__summary-section">
|
||||
<header className="warning-content__summary">
|
||||
<div className="warning-content__summary-left">
|
||||
<div className="warning-content__icon-wrapper">
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
{(warningCode || warningMessage) && (
|
||||
<div className="warning-content__summary-left">
|
||||
<div className="warning-content__icon-wrapper">
|
||||
<ErrorIcon />
|
||||
</div>
|
||||
|
||||
<div className="warning-content__summary-text">
|
||||
<h2 className="warning-content__warning-code">{warningCode}</h2>
|
||||
<p className="warning-content__warning-message">{warningMessage}</p>
|
||||
<div className="warning-content__summary-text">
|
||||
<h2 className="warning-content__warning-code">{warningCode}</h2>
|
||||
<p className="warning-content__warning-message">{warningMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warningUrl && (
|
||||
<div className="warning-content__summary-right">
|
||||
@@ -154,6 +157,10 @@ function WarningPopover({
|
||||
overlayInnerStyle={{ padding: 0 }}
|
||||
autoAdjustOverflow
|
||||
{...popoverProps}
|
||||
overlayClassName={cx(
|
||||
'warning-popover-overlay',
|
||||
popoverProps.overlayClassName,
|
||||
)}
|
||||
>
|
||||
{children || (
|
||||
<TriangleAlert
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
useGetMetricAlerts,
|
||||
useGetMetricDashboards,
|
||||
useGetMetricDashboardsV2,
|
||||
} from 'api/generated/services/metrics';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -38,7 +38,7 @@ function DashboardsAndAlertsPopover({
|
||||
data: dashboardsData,
|
||||
isLoading: isLoadingDashboards,
|
||||
isError: isErrorDashboards,
|
||||
} = useGetMetricDashboards(
|
||||
} = useGetMetricDashboardsV2(
|
||||
{
|
||||
metricName,
|
||||
},
|
||||
@@ -55,7 +55,8 @@ function DashboardsAndAlertsPopover({
|
||||
|
||||
const dashboards = useMemo(() => {
|
||||
const currentDashboards = dashboardsData?.data.dashboards ?? [];
|
||||
// Remove duplicate dashboards
|
||||
// The API returns one entry per referencing panel, so a dashboard repeats
|
||||
// once per panel that uses the metric.
|
||||
return currentDashboards.filter(
|
||||
(dashboard, index, self) =>
|
||||
index === self.findIndex((t) => t.dashboardId === dashboard.dashboardId),
|
||||
|
||||
@@ -23,7 +23,7 @@ const useGetMetricAlertsMock = jest.spyOn(
|
||||
);
|
||||
const useGetMetricDashboardsMock = jest.spyOn(
|
||||
metricsExplorerHooks,
|
||||
'useGetMetricDashboards',
|
||||
'useGetMetricDashboardsV2',
|
||||
);
|
||||
|
||||
describe('DashboardsAndAlertsPopover', () => {
|
||||
@@ -153,11 +153,15 @@ describe('DashboardsAndAlertsPopover', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders unique dashboards even when there are duplicates', async () => {
|
||||
it('collapses multiple panels of the same dashboard into one entry', async () => {
|
||||
useGetMetricDashboardsMock.mockReturnValue(
|
||||
getMockDashboardsData({
|
||||
data: {
|
||||
dashboards: [MOCK_DASHBOARD_1, MOCK_DASHBOARD_2, MOCK_DASHBOARD_1],
|
||||
dashboards: [
|
||||
MOCK_DASHBOARD_1,
|
||||
MOCK_DASHBOARD_2,
|
||||
{ ...MOCK_DASHBOARD_1, panelId: '3', panelName: 'Panel 3' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as metricsExplorerHooks from 'api/generated/services/metrics';
|
||||
import {
|
||||
GetMetricAlerts200,
|
||||
GetMetricAttributes200,
|
||||
GetMetricDashboards200,
|
||||
GetMetricDashboardsV2200,
|
||||
GetMetricHighlights200,
|
||||
GetMetricMetadata200,
|
||||
MetrictypesTemporalityDTO,
|
||||
@@ -40,14 +40,14 @@ export function getMockMetricHighlightsData(
|
||||
export const MOCK_DASHBOARD_1 = {
|
||||
dashboardName: 'Dashboard 1',
|
||||
dashboardId: '1',
|
||||
widgetId: '1',
|
||||
widgetName: 'Widget 1',
|
||||
panelId: '1',
|
||||
panelName: 'Panel 1',
|
||||
};
|
||||
export const MOCK_DASHBOARD_2 = {
|
||||
dashboardName: 'Dashboard 2',
|
||||
dashboardId: '2',
|
||||
widgetId: '2',
|
||||
widgetName: 'Widget 2',
|
||||
panelId: '2',
|
||||
panelName: 'Panel 2',
|
||||
};
|
||||
export const MOCK_ALERT_1 = {
|
||||
alertName: 'Alert 1',
|
||||
@@ -59,7 +59,7 @@ export const MOCK_ALERT_2 = {
|
||||
};
|
||||
|
||||
export function getMockDashboardsData(
|
||||
overrides?: Partial<GetMetricDashboards200>,
|
||||
overrides?: Partial<GetMetricDashboardsV2200>,
|
||||
{
|
||||
isLoading = false,
|
||||
isError = false,
|
||||
@@ -67,7 +67,7 @@ export function getMockDashboardsData(
|
||||
isLoading?: boolean;
|
||||
isError?: boolean;
|
||||
} = {},
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards> {
|
||||
): ReturnType<typeof metricsExplorerHooks.useGetMetricDashboardsV2> {
|
||||
return {
|
||||
data: {
|
||||
data: {
|
||||
@@ -79,7 +79,7 @@ export function getMockDashboardsData(
|
||||
|
||||
isLoading,
|
||||
isError,
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboards>;
|
||||
} as ReturnType<typeof metricsExplorerHooks.useGetMetricDashboardsV2>;
|
||||
}
|
||||
|
||||
export function getMockAlertsData(
|
||||
|
||||
@@ -11,3 +11,9 @@
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Highlights the dashboard name inside the delete-confirm title. */
|
||||
.deleteName {
|
||||
color: var(--danger-background);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -27,16 +27,13 @@ import logEvent from 'api/common/logEvent';
|
||||
import { cloneDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import history from 'lib/history';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
@@ -45,6 +42,7 @@ import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitl
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
@@ -84,8 +82,12 @@ function DashboardActions({
|
||||
const [isCloning, setIsCloning] = useState<boolean>(false);
|
||||
const [isNewSectionOpen, setIsNewSectionOpen] = useState<boolean>(false);
|
||||
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
const { contextHolder: deleteConfirmHolder, confirmDeleteDashboard } =
|
||||
useDeleteDashboardAction({
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: title,
|
||||
panelCount: Object.keys(dashboard.spec.panels).length,
|
||||
});
|
||||
|
||||
// Open the settings drawer when something in the tree requests it (e.g. the
|
||||
// variables bar's "Add variable" button).
|
||||
@@ -132,19 +134,6 @@ function DashboardActions({
|
||||
}
|
||||
}, [dashboard.id, title, safeNavigate, showErrorModal]);
|
||||
|
||||
const handleConfirmDelete = useCallback((): void => {
|
||||
deleteDashboardMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
void logEvent(DashboardDetailEvents.Deleted, {
|
||||
dashboardId: dashboard.id,
|
||||
panelCount: Object.keys(dashboard.spec.panels).length,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
history.replace(ROUTES.ALL_DASHBOARD);
|
||||
},
|
||||
});
|
||||
}, [deleteDashboardMutation, dashboard.id, dashboard.spec.panels]);
|
||||
|
||||
const handleOpenSettings = useCallback((): void => {
|
||||
void logEvent(DashboardDetailEvents.SettingsOpened, {
|
||||
dashboardId: dashboard.id,
|
||||
@@ -248,7 +237,7 @@ function DashboardActions({
|
||||
icon: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
disabled: isLocked,
|
||||
onClick: (): void => setIsDeleteOpen(true),
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -266,6 +255,7 @@ function DashboardActions({
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
handleEnterFullScreen,
|
||||
confirmDeleteDashboard,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -348,14 +338,7 @@ function DashboardActions({
|
||||
isOpen={isJsonEditorOpen}
|
||||
onClose={(): void => setIsJsonEditorOpen(false)}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
title={`Delete dashboard"?`}
|
||||
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
|
||||
isLoading={deleteDashboardMutation.isLoading}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onClose={(): void => setIsDeleteOpen(false)}
|
||||
/>
|
||||
{deleteConfirmHolder}
|
||||
<SectionTitleModal
|
||||
open={isNewSectionOpen}
|
||||
heading="New section"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { type ReactNode, useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
invalidateListDashboardsForUserV2,
|
||||
useDeleteDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
|
||||
import history from 'lib/history';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './DashboardActions.module.scss';
|
||||
|
||||
interface UseDeleteDashboardActionArgs {
|
||||
dashboardId: string;
|
||||
dashboardName: string;
|
||||
panelCount: number;
|
||||
}
|
||||
|
||||
interface UseDeleteDashboardAction {
|
||||
/** Must be rendered in the calling component for the modal to appear. */
|
||||
contextHolder: ReactNode;
|
||||
confirmDeleteDashboard: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the open dashboard through the v2 endpoint behind the shared
|
||||
* destructive confirmation, then drops its local preferences, refreshes the
|
||||
* dashboard list cache and sends the user back to the list.
|
||||
*/
|
||||
export function useDeleteDashboardAction({
|
||||
dashboardId,
|
||||
dashboardName,
|
||||
panelCount,
|
||||
}: UseDeleteDashboardActionArgs): UseDeleteDashboardAction {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
const removePreferences = useDashboardPreferencesStore(
|
||||
(state) => state.removePreferences,
|
||||
);
|
||||
|
||||
const { mutate: deleteDashboard } = useDeleteDashboardV2({
|
||||
mutation: {
|
||||
onSuccess: async (): Promise<void> => {
|
||||
void logEvent(DashboardDetailEvents.Deleted, { dashboardId, panelCount });
|
||||
removePreferences(dashboardId);
|
||||
await invalidateListDashboardsForUserV2(queryClient);
|
||||
toast.success('Dashboard deleted successfully');
|
||||
history.replace(ROUTES.ALL_DASHBOARD);
|
||||
},
|
||||
onError: (error: unknown): void => {
|
||||
showErrorModal(error as APIError);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDeleteDashboard = useCallback((): void => {
|
||||
confirmDelete({
|
||||
title: (
|
||||
<Typography.Title level={5}>
|
||||
Are you sure you want to delete the
|
||||
<Typography.Text className={styles.deleteName}>
|
||||
{' '}
|
||||
{dashboardName}{' '}
|
||||
</Typography.Text>
|
||||
dashboard?
|
||||
</Typography.Title>
|
||||
),
|
||||
content: 'This action cannot be undone.',
|
||||
// Keeps the Delete button loading until the mutation settles, then closes.
|
||||
onConfirm: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
deleteDashboard(
|
||||
{ pathParams: { id: dashboardId } },
|
||||
{ onSettled: () => resolve() },
|
||||
);
|
||||
}),
|
||||
});
|
||||
}, [confirmDelete, dashboardName, deleteDashboard, dashboardId]);
|
||||
|
||||
return { contextHolder, confirmDeleteDashboard };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SolidAlertTriangle, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
@@ -13,6 +14,7 @@ import DisabledControlTooltip from '../../components/DisabledControlTooltip/Disa
|
||||
import styles from './Header.module.scss';
|
||||
|
||||
interface HeaderProps {
|
||||
/** Unsaved edits exist — shows the "Unsaved Changes" badge and gates the discard confirmation on close (not the Save button). */
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
showSwitchToView?: boolean;
|
||||
@@ -66,6 +68,11 @@ function Header({
|
||||
/>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text>Configure panel</Typography.Text>
|
||||
{isDirty && (
|
||||
<Badge color="warning" data-testid="panel-editor-v2-unsaved-badge">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<HeaderRightSection
|
||||
@@ -88,7 +95,7 @@ function Header({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="panel-editor-v2-save"
|
||||
disabled={readOnly || !isDirty || isSaving}
|
||||
disabled={readOnly || isSaving}
|
||||
loading={!readOnly && isSaving}
|
||||
onClick={readOnly ? undefined : onSave}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
@@ -23,7 +24,9 @@ jest.mock('api/common/logEvent', () => ({
|
||||
|
||||
const mockUseIsAIAssistantEnabled = useIsAIAssistantEnabled as jest.Mock;
|
||||
|
||||
function renderHeader(): void {
|
||||
function renderHeader(
|
||||
props: Partial<ComponentProps<typeof Header>> = {},
|
||||
): void {
|
||||
// AppLayout supplies the TooltipProvider in the app; the header is rendered bare here.
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -33,6 +36,7 @@ function renderHeader(): void {
|
||||
isSaving={false}
|
||||
onSave={jest.fn()}
|
||||
onClose={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</MemoryRouter>,
|
||||
@@ -66,4 +70,34 @@ describe('PanelEditor Header', () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Save enabled even when there are no unsaved edits', () => {
|
||||
mockUseIsAIAssistantEnabled.mockReturnValue(false);
|
||||
|
||||
renderHeader({ isDirty: false });
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-save')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('disables Save only while read-only or saving', () => {
|
||||
mockUseIsAIAssistantEnabled.mockReturnValue(false);
|
||||
|
||||
renderHeader({ isDirty: true, readOnly: true, readOnlyReason: 'Locked' });
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the Unsaved Changes badge only when there are unsaved edits', () => {
|
||||
mockUseIsAIAssistantEnabled.mockReturnValue(false);
|
||||
|
||||
renderHeader({ isDirty: false });
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-unsaved-badge'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
renderHeader({ isDirty: true });
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-unsaved-badge'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
@@ -100,6 +101,12 @@ jest.mock('@signozhq/ui/resizable', () => ({
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
const mockShowErrorModal = jest.fn();
|
||||
jest.mock('providers/ErrorModalProvider', () => ({
|
||||
useErrorModal: (): { showErrorModal: jest.Mock } => ({
|
||||
showErrorModal: mockShowErrorModal,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Children mocked to capture props (and expose a Save trigger / footer slot).
|
||||
const mockHeaderProps = jest.fn();
|
||||
@@ -261,13 +268,13 @@ describe('PanelEditorContainer composition', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a query-less new panel unsaveable but still serializes its seed query', () => {
|
||||
it('keeps a query-less new panel clean but still serializes its seed query', () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'), { isNew: true });
|
||||
|
||||
expect(mockUseQuerySync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ alwaysSerializeQuery: true }),
|
||||
);
|
||||
// No query and no edits yet → nothing to save, so Save stays disabled.
|
||||
// No query and no edits yet → not dirty, so closing won't prompt to discard.
|
||||
expect(mockHeaderProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isDirty: false }),
|
||||
);
|
||||
@@ -290,7 +297,7 @@ describe('PanelEditorContainer composition', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a new panel that already has a query saveable (e.g. list auto-runs one)', () => {
|
||||
it('marks a new panel that already has a query dirty (e.g. list auto-runs one)', () => {
|
||||
const seededQuery = {
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { signal: 'logs' } } },
|
||||
};
|
||||
@@ -327,6 +334,24 @@ describe('PanelEditorContainer composition', () => {
|
||||
expect(mockSave).toHaveBeenCalledWith(panel.spec);
|
||||
});
|
||||
|
||||
it('surfaces a save failure through the error modal', async () => {
|
||||
// The raw thrown value flows straight to the modal, which normalizes it to an
|
||||
// APIError itself (that normalization is covered by ErrorModalProvider's tests).
|
||||
const failure = {
|
||||
response: {
|
||||
status: 400,
|
||||
data: { error: { code: 'INVALID', message: 'Panel name already exists' } },
|
||||
},
|
||||
};
|
||||
mockSave.mockRejectedValueOnce(failure);
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
await userEvent.click(screen.getByTestId('editor-save'));
|
||||
|
||||
await waitFor(() => expect(mockShowErrorModal).toHaveBeenCalledWith(failure));
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the saved panel to be scrolled into view on the dashboard', async () => {
|
||||
setup(makePanel('signoz/TimeSeriesPanel'));
|
||||
|
||||
|
||||
@@ -117,6 +117,49 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('a NEW panel stays query-dirty after Stage & Run commits the edited query into the draft', async () => {
|
||||
// Repro of the P0: new panel → edit → Run commits the query into the draft; the
|
||||
// dirty baseline must not drift onto it, or Save re-disables.
|
||||
const editedInUrl: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
id: 'edited-new-panel',
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'edited-legend',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const committed = toPerses(editedInUrl, panelType);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ draftQueries }: { draftQueries: DashboardtypesQueryDTO[] }) =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(draftQueries),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
alwaysSerializeQuery: true,
|
||||
signal: DataSource.METRICS as unknown as TelemetrytypesSignalDTO,
|
||||
// savedQueries omitted — new panel.
|
||||
}),
|
||||
{
|
||||
wrapper: makeUrlWrapper(editedInUrl),
|
||||
initialProps: { draftQueries: [] as DashboardtypesQueryDTO[] },
|
||||
},
|
||||
);
|
||||
|
||||
// The edited query (from the URL) diverges from the seeded default → dirty.
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
|
||||
|
||||
// Stage & Run commits the edited query into the draft → must stay dirty.
|
||||
rerender({ draftQueries: committed });
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('an untouched panel with a minimal/older stored query is NOT dirty (drift fix)', async () => {
|
||||
// An older saved query carries only a few fields; the builder re-emits many more
|
||||
// (source, stepInterval, filter, spaceAggregation, …). Comparing raw would read
|
||||
|
||||
@@ -79,6 +79,11 @@ export function usePanelEditorQuerySync({
|
||||
// in-editor edit in the URL survives a refresh / browser Back-Forward.
|
||||
useShareBuilderUrl({ defaultValue: seedQuery });
|
||||
|
||||
// Frozen seed for the new-panel dirty baseline: Stage & Run commits the live query
|
||||
// into `draft.spec.queries`, so re-reading `seedQuery` would drift the baseline onto
|
||||
// the run query and Save would re-disable right after a run.
|
||||
const initialSeedRef = useRef(seedQuery);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches).
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
@@ -152,16 +157,19 @@ export function usePanelEditorQuerySync({
|
||||
// the V5 envelope level. Anchoring to `savedQueries` (not the builder-seed) keeps a
|
||||
// handed-off / URL-restored edit reading as dirty; routing both sides through the same
|
||||
// `fromPerses → toPerses` round-trip stops builder-added defaults (absent from an older
|
||||
// stored query) reading an untouched panel as modified. New panel: fall back to seed.
|
||||
// stored query) reading an untouched panel as modified. New panel: fall back to the
|
||||
// frozen initial seed (see `initialSeedRef`).
|
||||
const baselineEnvelopes = useMemo(
|
||||
() =>
|
||||
toQueryEnvelopes(
|
||||
toPerses(
|
||||
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
|
||||
savedQueries
|
||||
? fromPerses(savedQueries, panelType)
|
||||
: initialSeedRef.current,
|
||||
panelType,
|
||||
),
|
||||
),
|
||||
[savedQueries, seedQuery, panelType],
|
||||
[savedQueries, panelType],
|
||||
);
|
||||
const isQueryDirty = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
@@ -159,9 +160,10 @@ function PanelEditorContainer({
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// New panels are savable once seeded with a query (List auto-seeds one). Read the
|
||||
// seed `panel`, not the live `draft` — the staged-query sync commits the seed into
|
||||
// the draft on open, which would falsely dirty an untouched query-less new panel.
|
||||
// Unsaved-edits flag driving the discard confirmation on close (Save is always
|
||||
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
|
||||
// commits the seed into the draft on open, which would falsely dirty an untouched
|
||||
// query-less new panel.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
|
||||
@@ -226,6 +228,7 @@ function PanelEditorContainer({
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
@@ -236,12 +239,22 @@ function PanelEditorContainer({
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved');
|
||||
toast.success('Panel saved', {
|
||||
position: 'top-center',
|
||||
});
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error('Failed to save panel');
|
||||
} catch (err) {
|
||||
showErrorModal(err);
|
||||
}
|
||||
}, [isEditable, save, buildSaveSpec, draft.spec, setScrollTargetId, onSaved]);
|
||||
}, [
|
||||
isEditable,
|
||||
save,
|
||||
buildSaveSpec,
|
||||
draft.spec,
|
||||
setScrollTargetId,
|
||||
onSaved,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('panelStatusFromError', () => {
|
||||
|
||||
it('falls back to the error message when there is no structured body', () => {
|
||||
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
|
||||
code: 'unknown_error',
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
message: 'boom',
|
||||
docsUrl: undefined,
|
||||
messages: [],
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
deleteDashboardV2,
|
||||
invalidateListDashboardsForUserV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
|
||||
import styles from './ActionsPopover.module.scss';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -6,11 +6,11 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { Bookmark, PenLine, Plus, Search, Trash2 } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
|
||||
import { DashboardListEvents } from 'pages/DashboardsListPageV2/constants/events';
|
||||
|
||||
import type { SavedView } from '../../types';
|
||||
import { type BuiltinView } from '../../utils/views';
|
||||
import { useDeleteConfirm } from '../DeleteConfirmModal/useDeleteConfirm';
|
||||
import ViewNamePopover from './ViewNamePopover';
|
||||
|
||||
import styles from './ViewsRail.module.scss';
|
||||
|
||||
@@ -42,7 +42,12 @@ export function toAPIError(
|
||||
try {
|
||||
ErrorResponseHandlerForGeneratedAPIs(error);
|
||||
} catch (apiError) {
|
||||
if (apiError instanceof APIError) {
|
||||
// UPSTREAM_UNAVAILABLE means the handler couldn't find a backend error code
|
||||
// (non-envelope body); prefer the caller's context-specific defaultMessage.
|
||||
if (
|
||||
apiError instanceof APIError &&
|
||||
apiError.getErrorCode() !== 'UPSTREAM_UNAVAILABLE'
|
||||
) {
|
||||
return apiError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,9 +100,20 @@ func (q *chSQLQuery) renderVars(query string, vars map[string]qbtypes.VariableIt
|
||||
return newQuery.String(), nil
|
||||
}
|
||||
|
||||
// Statement renders the SQL without executing it, for the preview path.
|
||||
func (q *chSQLQuery) Statement(_ context.Context) (*qbtypes.Statement, error) {
|
||||
func (q *chSQLQuery) render(ctx context.Context) (string, error) {
|
||||
rendered, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
querybuilder.LogIfStatementIsNotValid(ctx, q.logger, rendered)
|
||||
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
// Statement renders the SQL without executing it, for the preview path.
|
||||
func (q *chSQLQuery) Statement(ctx context.Context) (*qbtypes.Statement, error) {
|
||||
rendered, err := q.render(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -124,7 +135,7 @@ func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
elapsed += p.Elapsed
|
||||
}))
|
||||
|
||||
query, err := q.renderVars(q.query.Query, q.vars, q.fromMS, q.toMS)
|
||||
query, err := q.render(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -135,11 +146,11 @@ func (q *chSQLQuery) Execute(ctx context.Context) (*qbtypes.Result, error) {
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// TODO: map the errors from ClickHouse to our error types
|
||||
payload, err := consume(rows, q.kind, nil, qbtypes.Step{}, q.query.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &qbtypes.Result{
|
||||
Type: q.kind,
|
||||
Value: payload,
|
||||
|
||||
@@ -331,11 +331,14 @@ 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)
|
||||
}
|
||||
|
||||
// Hide only known SigNoz storage keys: label names are user data and may
|
||||
// legitimately start with "__" (e.g. __address__), so a blanket dunder
|
||||
// strip mangles user labelsets. The __scope./__resource. prefixes cover
|
||||
// every exporter version's keys.
|
||||
excludeLabel := func(labelName string) bool {
|
||||
if labelName == "__name__" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(labelName, "__") || labelName == "fingerprint"
|
||||
return labelName == "__temporality__" ||
|
||||
strings.HasPrefix(labelName, "__scope.") ||
|
||||
strings.HasPrefix(labelName, "__resource.")
|
||||
}
|
||||
|
||||
var series []*qbv5.TimeSeries
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/modules/thirdpartyapi"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
|
||||
"log/slog"
|
||||
@@ -1092,6 +1093,8 @@ func (aH *APIHandler) queryDashboardVarsV2(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
querybuilder.LogIfStatementIsNotValid(r.Context(), aH.logger, query)
|
||||
|
||||
dashboardVars, err := aH.reader.QueryDashboardVars(r.Context(), query)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
|
||||
76
pkg/querybuilder/clickhouse_sql.go
Normal file
76
pkg/querybuilder/clickhouse_sql.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// internalDatabases hold server metadata, credentials and grants rather than telemetry.
|
||||
var internalDatabases = map[string]struct{}{
|
||||
"system": {},
|
||||
"information_schema": {},
|
||||
}
|
||||
|
||||
// The parser's grammar has gaps against SQL that ClickHouse itself accepts. See TestErrIfStatementIsNotValid_ShouldPassButFails.
|
||||
func ErrIfStatementIsNotValid(query string) (err error) {
|
||||
defer func() {
|
||||
// The parser has a history of panicking on malformed input rather than returning an error.
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid ClickHouse SQL (recovered): %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
stmts, parseErr := chparser.NewParser(query).ParseStmts()
|
||||
if parseErr != nil {
|
||||
// Wrapped rather than formatted in, so that callers can recover the parser's *ParseError and read the position off it.
|
||||
return errors.WrapInvalidInputf(parseErr, errors.CodeInvalidInput, "invalid ClickHouse SQL: %s", parseErr.Error())
|
||||
}
|
||||
|
||||
if len(stmts) != 1 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse SQL must contain exactly one statement, found %d statements", len(stmts))
|
||||
}
|
||||
|
||||
selectQuery, ok := stmts[0].(*chparser.SelectQuery)
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "only SELECT statements are allowed in ClickHouse SQL queries")
|
||||
}
|
||||
|
||||
visitor := &chparser.DefaultASTVisitor{Visit: func(node chparser.Expr) error {
|
||||
switch expr := node.(type) {
|
||||
case *chparser.TableFunctionExpr:
|
||||
// Source table functions remain usable in ClickHouse read-only mode.
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "ClickHouse table functions are not allowed in SQL queries: %s", chparser.Format(expr.Name))
|
||||
|
||||
case *chparser.TableIdentifier:
|
||||
// Reading these is unaffected by ClickHouse read-only mode.
|
||||
if expr.Database == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, ok := internalDatabases[strings.ToLower(expr.Database.Name)]; ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse %s database is not allowed in SQL queries", expr.Database.Name)
|
||||
}
|
||||
|
||||
case *chparser.SettingExpr:
|
||||
// A query-level setting takes precedence over the context setting.
|
||||
if strings.EqualFold(expr.Name.Name, "readonly") {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "the ClickHouse readonly setting cannot be overridden")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}}
|
||||
|
||||
return selectQuery.Accept(visitor)
|
||||
}
|
||||
|
||||
// TODO(@therealpandey): remove this and move to ErrIfStatementIsNotValid.
|
||||
func LogIfStatementIsNotValid(ctx context.Context, logger *slog.Logger, query string) {
|
||||
if err := ErrIfStatementIsNotValid(query); err != nil {
|
||||
logger.WarnContext(ctx, "clickhouse sql is not valid", errors.Attr(err), slog.String("query", query))
|
||||
}
|
||||
}
|
||||
154
pkg/querybuilder/clickhouse_sql_test.go
Normal file
154
pkg/querybuilder/clickhouse_sql_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestErrIfStatementIsNotValid_Pass(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
// Shapes a telemetry read is allowed to take.
|
||||
{"Select", "SELECT region AS r, zone FROM metrics WHERE metric_name = 'cpu' GROUP BY region, zone"},
|
||||
{"TrailingSemicolon", "SELECT count() FROM signoz_logs.distributed_logs_v2;"},
|
||||
{"CommonTableExpression", "WITH t AS (SELECT fingerprint FROM signoz_metrics.time_series_v4) SELECT * FROM t"},
|
||||
{"Join", "SELECT * FROM t1 LEFT JOIN t2 ON t1.a = t2.b"},
|
||||
{"GlobalIn", "SELECT a FROM t WHERE a GLOBAL IN (SELECT b FROM t2)"},
|
||||
{"Union", "SELECT * FROM t UNION ALL SELECT * FROM t2"},
|
||||
{"Intersect", "SELECT * FROM t INTERSECT SELECT * FROM t2"},
|
||||
{"WindowFunction", "SELECT sum(v) OVER (PARTITION BY a ORDER BY t) FROM t"},
|
||||
{"UnrelatedSetting", "SELECT * FROM t SETTINGS max_threads = 4"},
|
||||
{"TerminatedBlockComment", "SELECT /* keep me */ count() FROM t"},
|
||||
{"BlockCommentMarkerInsideStringLiteral", "SELECT count() FROM t WHERE body = '/* not a comment'"},
|
||||
// The parser used to loop forever on this; it now reads the comment to the end of
|
||||
// the input, so this doubles as a canary for that regression.
|
||||
{"TrailingUnterminatedBlockComment", "SELECT count() FROM t /* unterminated"},
|
||||
// The rule keys on the database, not on the table name.
|
||||
{"TableNamedSystemInTelemetryDatabase", "SELECT * FROM signoz_logs.system"},
|
||||
{"SignedLiteralAfterClosingParen", "SELECT (toUnixTimestamp(now()) - 3600)*1000000000"},
|
||||
// order by interval
|
||||
{"OrderByInterval", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval ORDER BY interval"},
|
||||
{"OrderByIntervalAndDirection", "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS `interval` ORDER BY `interval` ASC"},
|
||||
{"TrimFunction", "SELECT trimBoth('/api/endpoint/', '/');"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := ErrIfStatementIsNotValid(testCase.query)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrIfStatementIsNotValid_Fail(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
}{
|
||||
// Not a single statement, or not a statement at all.
|
||||
{"Empty", ""},
|
||||
{"UnterminatedBlockCommentOnly", "/* x"},
|
||||
{"Unparseable", "SELECT FROM WHERE"},
|
||||
{"MultipleStatements", "SELECT 1; DROP TABLE signoz_logs.logs_v2"},
|
||||
// Parses, but is not a SELECT.
|
||||
{"Drop", "DROP TABLE signoz_logs.logs_v2"},
|
||||
{"Insert", "INSERT INTO signoz_logs.logs_v2 SELECT * FROM signoz_logs.logs_v2"},
|
||||
{"AlterDelete", "ALTER TABLE signoz_logs.logs_v2 DELETE WHERE 1 = 1"},
|
||||
{"CreateTable", "CREATE TABLE evil (a Int) ENGINE = Memory"},
|
||||
{"Grant", "GRANT ALL ON *.* TO admin"},
|
||||
{"Set", "SET readonly = 0"},
|
||||
// These the parser rejects outright rather than classifying.
|
||||
{"ShowGrants", "SHOW GRANTS"},
|
||||
{"IntoOutfile", "SELECT * FROM t INTO OUTFILE '/tmp/x.csv'"},
|
||||
// Table functions, which read through something other than a telemetry table.
|
||||
{"UrlTableFunction", "SELECT * FROM url('http://attacker.example/x', CSV, 'a String')"},
|
||||
{"FileTableFunction", "SELECT * FROM file('/etc/passwd', CSV, 'a String')"},
|
||||
{"ExecutableTableFunction", "SELECT * FROM executable('script.sh', CSV, 'a String')"},
|
||||
{"TableFunctionInJoin", "SELECT * FROM t1 JOIN url('http://x', CSV, 'a String') u ON 1 = 1"},
|
||||
{"TableFunctionInCommonTableExpression", "WITH c AS (SELECT * FROM url('http://x', CSV, 'a String')) SELECT * FROM c"},
|
||||
{"TableFunctionInWhereSubquery", "SELECT * FROM t WHERE a IN (SELECT * FROM file('/etc/passwd', CSV, 'a String'))"},
|
||||
{"TableFunctionInUnion", "SELECT * FROM t UNION ALL SELECT * FROM url('http://x', CSV, 'a String')"},
|
||||
// Internal databases, which hold grants and server metadata rather than telemetry.
|
||||
{"SystemUsers", "SELECT * FROM system.users"},
|
||||
{"SystemUppercase", "SELECT * FROM SYSTEM.USERS"},
|
||||
{"SystemQuoted", "SELECT count() FROM `system`.`tables`"},
|
||||
{"SystemInSubquery", "SELECT * FROM (SELECT name FROM system.parts)"},
|
||||
{"SystemInJoin", "SELECT * FROM signoz_logs.distributed_logs_v2 AS l JOIN system.users AS u ON 1 = 1"},
|
||||
{"SystemInIntersect", "SELECT * FROM t INTERSECT SELECT * FROM system.users"},
|
||||
{"InformationSchema", "SELECT * FROM information_schema.tables"},
|
||||
// A query-level setting takes precedence over the one the caller applies.
|
||||
{"ReadonlySettingOverride", "SELECT * FROM t SETTINGS readonly = 0"},
|
||||
{"ReadonlySettingOverrideAmongOthers", "SELECT * FROM t SETTINGS max_threads = 4, readonly = 0"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := ErrIfStatementIsNotValid(testCase.query)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Queries the parser cannot read. ClickHouse runs all of them.
|
||||
func TestErrIfStatementIsNotValid_ShouldPassButFails(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
query string
|
||||
// The construct the parser stops after, which is the one it cannot read.
|
||||
expectedStopsAfter string
|
||||
// The same construct written so the parser accepts it.
|
||||
fix string
|
||||
}{
|
||||
{
|
||||
name: "IntervalAliasInOrderBy",
|
||||
query: "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval, resource_string_service$$name AS `service.name`, attributes_string['http.route'] AS `http.route`, quantile(0.95)(duration_nano) / 1000000000 AS value FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_string_service$$name = 'svc-a' AND resources_string['deployment.environment'] = 'dev' AND attributes_string['http.route'] = '/v1' AND http_method = 'POST' AND timestamp BETWEEN toDateTime(1784601720) AND toDateTime(1784602620) AND ts_bucket_start BETWEEN 1784601720 - 1800 AND 1784602620 GROUP BY `service.name`, `http.route`, interval ORDER BY interval ASC",
|
||||
expectedStopsAfter: "ORDER BY interval ASC",
|
||||
fix: "SELECT count() AS interval FROM t ORDER BY `interval` ASC",
|
||||
},
|
||||
{
|
||||
name: "IntervalAliasInOrderByDesc",
|
||||
query: "SELECT count() AS value, toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS interval, serviceName, resourceTagsMap['deployment.environment'] AS environment, exceptionStacktrace FROM signoz_traces.distributed_signoz_error_index_v2 WHERE exceptionType != 'OSError' AND resourceTagsMap['deployment.environment'] = 'staging' AND timestamp BETWEEN toDateTime(1785186300) AND toDateTime(1785186600) GROUP BY serviceName, interval, environment, exceptionStacktrace ORDER BY interval DESC",
|
||||
expectedStopsAfter: "ORDER BY interval DESC",
|
||||
fix: "SELECT count() AS interval FROM t ORDER BY `interval` DESC",
|
||||
},
|
||||
{
|
||||
name: "StandardTrimSyntax",
|
||||
query: "SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 5 MINUTE) AS interval, resources_string['host.name'] as host_name, toFloat64(countIf( lower(trim(BOTH ' ' FROM replaceOne( JSONExtractString(body, 'Action'), 'health_status: ', '' ))) IN ('unhealthy','starting','failing') )) as value FROM signoz_logs.distributed_logs_v2 WHERE timestamp BETWEEN 1784602320000000000 AND 1784602620000000000 AND ts_bucket_start BETWEEN 1784602320 - 300 AND 1784602620 AND JSONExtractString(body, 'Type') = 'container' AND JSONExtractString(body, 'Actor', 'Attributes', 'name') IS NOT NULL AND resources_string['host.name'] IS NOT NULL AND resources_string['host.name'] = 'aihub-nightly' GROUP BY interval, host_name ORDER BY interval, host_name",
|
||||
expectedStopsAfter: "trim(BOTH '",
|
||||
fix: "SELECT trimBoth(replaceOne( JSONExtractString(body, 'Action'), 'health_status: ', '' ), ' ')",
|
||||
},
|
||||
{
|
||||
name: "SignedLiteralAfterClosingParen",
|
||||
query: "SELECT now() AS ts, toFloat64(count()) AS value FROM ( SELECT attributes_string['TableName'] AS T, attributes_string['MissingId'] AS M, max(fromUnixTimestamp64Nano(timestamp)) AS last_seen, dateDiff('minute', min(fromUnixTimestamp64Nano(timestamp)), max(fromUnixTimestamp64Nano(timestamp))) AS age_min FROM signoz_logs.distributed_logs_v2 WHERE body='missing_map_record' AND timestamp >= (toUnixTimestamp(now())-3600)*1000000000 GROUP BY T, M ) WHERE age_min >= 20 AND last_seen >= now() - toIntervalMinute(8)",
|
||||
expectedStopsAfter: "(toUnixTimestamp(now())",
|
||||
fix: "SELECT (toUnixTimestamp(now()) - 3600) * 1000000000",
|
||||
},
|
||||
{
|
||||
name: "SignedLiteralAfterClosingParenMinimal",
|
||||
query: "SELECT (1)-1",
|
||||
expectedStopsAfter: "(1)",
|
||||
fix: "SELECT (1) - 1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := ErrIfStatementIsNotValid(testCase.query)
|
||||
|
||||
var parseErr *chparser.ParseError
|
||||
require.ErrorAs(t, err, &parseErr, "expected a parser failure rather than a rule violation")
|
||||
|
||||
// The parser reports the offset it stopped at, which sits just past the construct
|
||||
// it choked on, so the text leading up to it is what needs looking at.
|
||||
consumed := testCase.query[:parseErr.Pos]
|
||||
assert.Equal(t, testCase.expectedStopsAfter, consumed[max(0, len(consumed)-len(testCase.expectedStopsAfter)):])
|
||||
|
||||
assert.NoError(t, ErrIfStatementIsNotValid(testCase.fix))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package querybuilder
|
||||
@@ -189,7 +189,9 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
|
||||
queries = dropUnrenderableQueries(queries)
|
||||
for _, q := range queries {
|
||||
name := d.readString(q, "queryName")
|
||||
out = append(out, qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
|
||||
env := qb.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue()))
|
||||
carryQuerySource(env, q)
|
||||
out = append(out, env)
|
||||
if signal.IsZero() {
|
||||
signal = signalFromDataSource(q["dataSource"])
|
||||
}
|
||||
@@ -446,3 +448,16 @@ func signalFromDataSource(raw any) telemetrytypes.Signal {
|
||||
}
|
||||
return telemetrytypes.Signal{}
|
||||
}
|
||||
|
||||
// carryQuerySource copies a v1 builder query's meter source onto the v5 spec.
|
||||
// WrapInV5Envelope maps dataSource→signal but ignores the separate `source` field, so a
|
||||
// meter query (dataSource metrics + source "meter") would otherwise migrate as plain
|
||||
// metrics and read the wrong data. Only "meter" is carried through — the sole non-empty
|
||||
// source the v1 UI produces and v5 accepts; anything else stays unspecified.
|
||||
func carryQuerySource(env, query map[string]any) {
|
||||
if source, _ := query["source"].(string); source == telemetrytypes.SourceMeter.StringValue() {
|
||||
if spec, ok := env["spec"].(map[string]any); ok {
|
||||
spec["source"] = source
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
@@ -1779,6 +1780,41 @@ func TestConvertV1WidgetQueryRewritesUnknownMetricValueOrderKey(t *testing.T) {
|
||||
assert.Equal(t, "service.name", spec.Order[1].Key.Name, "valid group-by order key left alone")
|
||||
}
|
||||
|
||||
// A v1 meter query is dataSource "metrics" with a separate source "meter". The signal
|
||||
// maps to metrics, and the meter source must survive onto the v5 spec — otherwise the
|
||||
// panel migrates as a plain metrics query and reads the wrong data.
|
||||
func TestConvertV1WidgetQueryCarriesMeterSource(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "meter-1",
|
||||
"panelTypes": "graph",
|
||||
"query": map[string]any{
|
||||
"queryType": "builder",
|
||||
"builder": map[string]any{
|
||||
"queryData": []any{
|
||||
map[string]any{
|
||||
"queryName": "A",
|
||||
"expression": "A",
|
||||
"dataSource": "metrics",
|
||||
"source": "meter",
|
||||
"aggregations": []any{map[string]any{"metricName": "signoz.meter.log.size", "spaceAggregation": "sum"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := (&v1Decoder{}).convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
require.Len(t, queries, 1)
|
||||
|
||||
wrapper, ok := queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec)
|
||||
require.True(t, ok)
|
||||
spec, ok := wrapper.Spec.(qb.QueryBuilderQuery[qb.MetricAggregation])
|
||||
require.True(t, ok, "meter query should dispatch to MetricAggregation, got %T", wrapper.Spec)
|
||||
|
||||
assert.Equal(t, telemetrytypes.SignalMetrics, spec.Signal, "signal maps from dataSource")
|
||||
assert.Equal(t, telemetrytypes.SourceMeter, spec.Source, "meter source carried onto the v5 spec")
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryInjectsCountForNoopOnAggregationPanel(t *testing.T) {
|
||||
// A logs query with the list-style "noop" operator placed on an aggregation
|
||||
// panel (graph). createAggregationsShapeSafe drops noop, leaving no aggregation;
|
||||
|
||||
2
scripts/promqltestcorpus/.gitignore
vendored
Normal file
2
scripts/promqltestcorpus/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# go build artifact (go run . is the supported entry)
|
||||
/promqltestcorpus
|
||||
660
scripts/promqltestcorpus/corpus.go
Normal file
660
scripts/promqltestcorpus/corpus.go
Normal file
@@ -0,0 +1,660 @@
|
||||
// SigNoz corpus policy: which upstream cases are representable through the
|
||||
// API, the grid variants that steer coarse-step code paths, the API's value
|
||||
// rounding, and the frozen JSON model. Nothing in this file mirrors
|
||||
// upstream code; it encodes what our conformance harness needs.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/model/value"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/prometheus/prometheus/tsdb/chunkenc"
|
||||
"github.com/prometheus/prometheus/util/almost"
|
||||
"github.com/prometheus/prometheus/util/teststorage"
|
||||
)
|
||||
|
||||
// seriesDescParser is the slice of parser.Parser the loader needs.
|
||||
type seriesDescParser interface {
|
||||
ParseSeriesDesc(input string) (labels.Labels, []parser.SequenceValue, error)
|
||||
}
|
||||
|
||||
// Calendar and wall-clock functions are not invariant under time
|
||||
// translation, and the Python suite shifts every case to recent timestamps
|
||||
// (epoch-0 samples would sit 55 years past ClickHouse TTLs). Everything else
|
||||
// PromQL computes depends only on time differences.
|
||||
var timeDependentFuncs = map[string]bool{
|
||||
"time": true, "timestamp": true, "month": true, "year": true,
|
||||
"minute": true, "hour": true, "day_of_month": true, "day_of_week": true,
|
||||
"day_of_year": true, "days_in_month": true,
|
||||
}
|
||||
|
||||
const (
|
||||
lookbackMs = 300_000
|
||||
instantStepMs = 1_000
|
||||
maxSamples = 50_000_000
|
||||
)
|
||||
|
||||
type corpusSeries struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
Samples [][2]any `json:"samples"` // [offset_ms, value]
|
||||
}
|
||||
|
||||
type corpusDataset struct {
|
||||
ID int `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Series []corpusSeries `json:"series"`
|
||||
}
|
||||
|
||||
type corpusPoint = [2]any // [offset_ms, value]
|
||||
|
||||
type corpusResult struct {
|
||||
Labels map[string]string `json:"labels"`
|
||||
Points []corpusPoint `json:"points"`
|
||||
}
|
||||
|
||||
type corpusCase struct {
|
||||
Dataset int `json:"dataset"`
|
||||
Source string `json:"source"`
|
||||
Variant string `json:"variant"`
|
||||
Expr string `json:"expr"`
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
StepMs int64 `json:"step_ms"`
|
||||
Instant bool `json:"instant"`
|
||||
Expected []corpusResult `json:"expected"`
|
||||
}
|
||||
|
||||
type corpus struct {
|
||||
Meta struct {
|
||||
PrometheusVersion string `json:"prometheus_version"`
|
||||
LookbackMs int64 `json:"lookback_ms"`
|
||||
InstantStepMs int64 `json:"instant_step_ms"`
|
||||
Note string `json:"note"`
|
||||
} `json:"meta"`
|
||||
Datasets []corpusDataset `json:"datasets"`
|
||||
Cases []corpusCase `json:"cases"`
|
||||
}
|
||||
|
||||
func generate(files []string, engine *promql.Engine, seriesParser parser.Parser, exprParser parser.Parser, promVersion string) (*corpus, map[string]int, error) {
|
||||
var c corpus
|
||||
c.Meta.PrometheusVersion = promVersion
|
||||
c.Meta.LookbackMs = lookbackMs
|
||||
c.Meta.InstantStepMs = instantStepMs
|
||||
c.Meta.Note = "expected values carry the API's 3-significant-decimal rounding (querybuildertypesv5 sanitizeValue); instant evals are encoded as start==end range queries"
|
||||
|
||||
skips := map[string]int{}
|
||||
datasetIDs := map[string]int{}
|
||||
|
||||
for _, file := range files {
|
||||
base := filepath.Base(file)
|
||||
if base == "native_histograms.test" || base == "type_and_unit.test" {
|
||||
// native histograms: the samples pipeline under test stores
|
||||
// floats; type_and_unit: experimental __type__/__unit__ metadata
|
||||
// labels our store does not materialize.
|
||||
skips["file:"+strings.TrimSuffix(base, ".test")]++
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cmds := parseScript(string(raw))
|
||||
|
||||
segment := 0
|
||||
var loads []command
|
||||
segmentBad := "" // non-empty: reason the segment cannot be represented
|
||||
for _, cmd := range cmds {
|
||||
switch cmd.kind {
|
||||
case "clear":
|
||||
segment++
|
||||
loads = nil
|
||||
segmentBad = ""
|
||||
case "skip":
|
||||
skips["command:"+cmd.head]++
|
||||
case "load":
|
||||
if reason := checkLoad(seriesParser, cmd); reason != "" {
|
||||
segmentBad = reason
|
||||
skips["load:"+reason]++
|
||||
continue
|
||||
}
|
||||
loads = append(loads, cmd)
|
||||
case "eval":
|
||||
if segmentBad != "" {
|
||||
skips["segment:"+segmentBad]++
|
||||
continue
|
||||
}
|
||||
if len(loads) == 0 {
|
||||
skips["eval:no-data"]++
|
||||
continue
|
||||
}
|
||||
ccs, reason, err := buildCases(engine, seriesParser, exprParser, cmd, loads, base, skips)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reason != "" {
|
||||
skips["eval:"+reason]++
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s#%d#%d", base, segment, len(loads))
|
||||
id, ok := datasetIDs[key]
|
||||
if !ok {
|
||||
ds, reason, err := dumpDataset(seriesParser, loads)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reason != "" {
|
||||
skips["dataset:"+reason]++
|
||||
continue
|
||||
}
|
||||
id = len(c.Datasets)
|
||||
datasetIDs[key] = id
|
||||
ds.ID = id
|
||||
ds.Source = key
|
||||
c.Datasets = append(c.Datasets, *ds)
|
||||
}
|
||||
for _, cc := range ccs {
|
||||
cc.Dataset = id
|
||||
cc.Source = fmt.Sprintf("%s:%d", base, cmd.line)
|
||||
c.Cases = append(c.Cases, cc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.Cases) == 0 {
|
||||
return nil, nil, fmt.Errorf("no corpus cases produced")
|
||||
}
|
||||
return &c, skips, nil
|
||||
}
|
||||
|
||||
func writeCorpus(out string, c *corpus) error {
|
||||
buf, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(out, buf, 0o644)
|
||||
}
|
||||
|
||||
// checkLoad validates a load block is representable: parsable series
|
||||
// notation, float samples only ("load_with_nhcb" and histogram literals are
|
||||
// out of scope — the samples pipeline under test stores float samples).
|
||||
func checkLoad(p parser.Parser, cmd command) string {
|
||||
fields := strings.Fields(cmd.head)
|
||||
if len(fields) != 2 || fields[0] != "load" {
|
||||
return "unsupported-load-variant"
|
||||
}
|
||||
if _, err := model.ParseDuration(fields[1]); err != nil {
|
||||
return "bad-interval"
|
||||
}
|
||||
for _, line := range cmd.body {
|
||||
metric, vals, err := p.ParseSeriesDesc(line)
|
||||
if err != nil {
|
||||
return "unparsable-series"
|
||||
}
|
||||
if metric.Get(model.MetricNameLabel) == "" {
|
||||
return "unnamed-series"
|
||||
}
|
||||
for _, v := range vals {
|
||||
if v.Histogram != nil {
|
||||
return "histogram-samples"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// durationExprUsesRange reports whether a duration-expression tree contains
|
||||
// range(). Instant evals are encoded as one-step range queries (the API
|
||||
// rejects start == end), which changes what range() evaluates to, so they
|
||||
// cannot carry it; range evals keep it — each variant's oracle is computed
|
||||
// on the exact window it requests.
|
||||
func durationExprUsesRange(e parser.Expr) bool {
|
||||
d, ok := e.(*parser.DurationExpr)
|
||||
if !ok || d == nil {
|
||||
return false
|
||||
}
|
||||
if d.Op == parser.RANGE {
|
||||
return true
|
||||
}
|
||||
return durationExprUsesRange(d.LHS) || durationExprUsesRange(d.RHS)
|
||||
}
|
||||
|
||||
// buildCases parses one eval header, filters unservable expressions, and
|
||||
// emits the base case plus grid variants — each with expectations computed by
|
||||
// the reference engine over the loads. The variants exist because upstream's
|
||||
// own grids are fine-stepped: without them the coarse-step code paths (the
|
||||
// window-sliver filter, the disjoint over_time form, the lifted instant/last
|
||||
// gates) would pass through this corpus untouched. A variant is just another
|
||||
// grid over the same data and expression; the engine is the oracle either way.
|
||||
func buildCases(engine *promql.Engine, seriesParser parser.Parser, exprParser parser.Parser, cmd command, loads []command, sourceFile string, skips map[string]int) ([]corpusCase, string, error) {
|
||||
for _, line := range cmd.body {
|
||||
if patExpect.MatchString(line) && strings.HasPrefix(line, "expect fail") {
|
||||
return nil, "expect-fail", nil
|
||||
}
|
||||
}
|
||||
|
||||
base := corpusCase{Variant: "base"}
|
||||
if m := patEvalInstant.FindStringSubmatch(cmd.head); m != nil {
|
||||
at, err := parseTestDuration(m[2])
|
||||
if err != nil {
|
||||
return nil, "bad-duration", nil
|
||||
}
|
||||
base.Instant = true
|
||||
base.StartMs, base.EndMs, base.StepMs = at, at, instantStepMs
|
||||
base.Expr = m[3]
|
||||
} else if m := patEvalRange.FindStringSubmatch(cmd.head); m != nil {
|
||||
from, err1 := parseTestDuration(m[2])
|
||||
to, err2 := parseTestDuration(m[3])
|
||||
step, err3 := parseTestDuration(m[4])
|
||||
if err1 != nil || err2 != nil || err3 != nil {
|
||||
return nil, "bad-duration", nil
|
||||
}
|
||||
if step <= 0 || to < from {
|
||||
return nil, "bad-grid", nil
|
||||
}
|
||||
base.StartMs, base.EndMs, base.StepMs = from, to, step
|
||||
base.Expr = m[5]
|
||||
} else {
|
||||
return nil, "unrecognized", nil
|
||||
}
|
||||
|
||||
expr, err := exprParser.ParseExpr(base.Expr)
|
||||
if err != nil {
|
||||
return nil, "needs-experimental-parser", nil
|
||||
}
|
||||
if vt := expr.Type(); vt != parser.ValueTypeVector && vt != parser.ValueTypeScalar {
|
||||
return nil, "non-instant-type", nil
|
||||
}
|
||||
unservable := ""
|
||||
hasSelector := false
|
||||
hasSubquery := false
|
||||
var maxRangeMs int64
|
||||
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
|
||||
switch n := node.(type) {
|
||||
case *parser.Call:
|
||||
if timeDependentFuncs[n.Func.Name] {
|
||||
unservable = "time-dependent"
|
||||
}
|
||||
case *parser.VectorSelector:
|
||||
hasSelector = true
|
||||
if n.Timestamp != nil || n.StartOrEnd != 0 {
|
||||
unservable = "at-modifier"
|
||||
}
|
||||
if n.OriginalOffset < 0 {
|
||||
// The server ships with negative offsets disabled.
|
||||
unservable = "negative-offset"
|
||||
}
|
||||
if base.Instant && durationExprUsesRange(n.OriginalOffsetExpr) {
|
||||
unservable = "range-duration-in-instant"
|
||||
}
|
||||
for _, m := range n.LabelMatchers {
|
||||
if m.Name == "__type__" || m.Name == "__unit__" {
|
||||
unservable = "type-unit-metadata"
|
||||
}
|
||||
}
|
||||
case *parser.MatrixSelector:
|
||||
if r := n.Range.Milliseconds(); r > maxRangeMs {
|
||||
maxRangeMs = r
|
||||
}
|
||||
if base.Instant && durationExprUsesRange(n.RangeExpr) {
|
||||
unservable = "range-duration-in-instant"
|
||||
}
|
||||
case *parser.SubqueryExpr:
|
||||
hasSubquery = true
|
||||
if n.Timestamp != nil || n.StartOrEnd != 0 {
|
||||
unservable = "at-modifier"
|
||||
}
|
||||
if n.OriginalOffset < 0 {
|
||||
unservable = "negative-offset"
|
||||
}
|
||||
if base.Instant && (durationExprUsesRange(n.RangeExpr) || durationExprUsesRange(n.StepExpr) || durationExprUsesRange(n.OriginalOffsetExpr)) {
|
||||
unservable = "range-duration-in-instant"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if unservable != "" {
|
||||
return nil, unservable, nil
|
||||
}
|
||||
|
||||
stor, err := loadSeriesStorage(seriesParser, loads)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer func() { _ = stor.Close() }()
|
||||
|
||||
expected, reason := computeExpected(engine, stor, base.Expr, base.StartMs, base.EndMs, base.StepMs)
|
||||
if reason != "" {
|
||||
return nil, reason, nil
|
||||
}
|
||||
if err := crossCheckUpstream(engine, seriesParser, stor, cmd, base, sourceFile, skips); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
base.Expected = expected
|
||||
out := []corpusCase{base}
|
||||
|
||||
// Grid variants. Subquery expressions keep their own inner grids; varying
|
||||
// the outer grid there multiplies cases without steering the code paths
|
||||
// the variants exist for, so they emit only the base.
|
||||
if hasSubquery || !hasSelector {
|
||||
return out, "", nil
|
||||
}
|
||||
type variant struct {
|
||||
name string
|
||||
startMs, endMs, stepMs int64
|
||||
}
|
||||
var variants []variant
|
||||
if base.Instant {
|
||||
// A coarse multi-point grid ending at the instant: step above the
|
||||
// 5m lookback drives the lifted instant gate and the sliver filter.
|
||||
const coarse = 600_000
|
||||
variants = append(variants, variant{"instant-coarse", base.EndMs - 2*coarse, base.EndMs, coarse})
|
||||
} else {
|
||||
span := base.EndMs - base.StartMs
|
||||
if maxRangeMs > 0 {
|
||||
// Step wider than every window in the expression: the sliver
|
||||
// filter and the disjoint over_time form become active.
|
||||
if coarse := 2 * maxRangeMs; span >= coarse {
|
||||
variants = append(variants, variant{"coarse-step", base.StartMs, base.EndMs, coarse})
|
||||
}
|
||||
// Whole-bucket tiling (range == 2 steps) drives the windowed
|
||||
// over_time slide with W = 2.
|
||||
if tiled := maxRangeMs / 2; tiled >= 1000 && maxRangeMs%2000 == 0 && tiled != base.StepMs && span >= tiled {
|
||||
variants = append(variants, variant{"tiled", base.StartMs, base.EndMs, tiled})
|
||||
}
|
||||
}
|
||||
// A start off every natural alignment shifts which samples each
|
||||
// window sees; an end short of the lattice exercises the
|
||||
// last-grid-point handling.
|
||||
if span > 17_000 {
|
||||
variants = append(variants, variant{"unaligned-start", base.StartMs + 17_000, base.EndMs, base.StepMs})
|
||||
}
|
||||
if lastIdx := span / base.StepMs; lastIdx >= 2 {
|
||||
offEnd := base.StartMs + lastIdx*base.StepMs - base.StepMs/3
|
||||
variants = append(variants, variant{"off-lattice-end", base.StartMs, offEnd, base.StepMs})
|
||||
}
|
||||
}
|
||||
for _, v := range variants {
|
||||
if v.stepMs <= 0 || v.endMs <= v.startMs || v.stepMs%1000 != 0 {
|
||||
continue
|
||||
}
|
||||
expected, reason := computeExpected(engine, stor, base.Expr, v.startMs, v.endMs, v.stepMs)
|
||||
if reason != "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, corpusCase{
|
||||
Variant: v.name, Expr: base.Expr,
|
||||
StartMs: v.startMs, EndMs: v.endMs, StepMs: v.stepMs,
|
||||
Expected: expected,
|
||||
})
|
||||
}
|
||||
return out, "", nil
|
||||
}
|
||||
|
||||
// computeExpected evaluates the expression on one grid with the reference
|
||||
// engine and serializes the result with the API's value rounding.
|
||||
func computeExpected(engine *promql.Engine, stor *teststorage.TestStorage, expr string, startMs, endMs, stepMs int64) ([]corpusResult, string) {
|
||||
qry, err := engine.NewRangeQuery(context.Background(), stor, nil, expr,
|
||||
time.UnixMilli(startMs), time.UnixMilli(endMs), time.Duration(stepMs)*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, "engine-parse"
|
||||
}
|
||||
defer qry.Close()
|
||||
res := qry.Exec(context.Background())
|
||||
if res.Err != nil {
|
||||
// Covers upstream's expected-error cases and engine features the
|
||||
// range form cannot evaluate; a case we cannot compute is a case we
|
||||
// cannot assert.
|
||||
return nil, "engine-error"
|
||||
}
|
||||
matrix, ok := res.Value.(promql.Matrix)
|
||||
if !ok {
|
||||
return nil, "non-matrix-result"
|
||||
}
|
||||
expected := []corpusResult{}
|
||||
for _, s := range matrix {
|
||||
if len(s.Histograms) > 0 {
|
||||
return nil, "histogram-result"
|
||||
}
|
||||
r := corpusResult{Labels: s.Metric.Map(), Points: []corpusPoint{}}
|
||||
for _, p := range s.Floats {
|
||||
r.Points = append(r.Points, corpusPoint{p.T, encodeFloat(roundToNonZeroDecimals(p.F, 3))})
|
||||
}
|
||||
expected = append(expected, r)
|
||||
}
|
||||
return expected, ""
|
||||
}
|
||||
|
||||
// dumpDataset walks the loaded storage and serializes every float sample.
|
||||
func dumpDataset(seriesParser parser.Parser, loads []command) (*corpusDataset, string, error) {
|
||||
stor, err := loadSeriesStorage(seriesParser, loads)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer func() { _ = stor.Close() }()
|
||||
|
||||
q, err := stor.Querier(math.MinInt64/2, math.MaxInt64/2)
|
||||
if err != nil {
|
||||
return nil, "querier", nil
|
||||
}
|
||||
defer q.Close()
|
||||
|
||||
ds := &corpusDataset{}
|
||||
ss := q.Select(context.Background(), true, nil, labels.MustNewMatcher(labels.MatchRegexp, model.MetricNameLabel, ".*"))
|
||||
var it chunkenc.Iterator
|
||||
for ss.Next() {
|
||||
s := ss.At()
|
||||
cs := corpusSeries{Labels: s.Labels().Map(), Samples: [][2]any{}}
|
||||
it = s.Iterator(it)
|
||||
for vt := it.Next(); vt != chunkenc.ValNone; vt = it.Next() {
|
||||
if vt != chunkenc.ValFloat {
|
||||
return nil, "histogram-samples", nil
|
||||
}
|
||||
ts, v := it.At()
|
||||
if value.IsStaleNaN(v) {
|
||||
cs.Samples = append(cs.Samples, [2]any{ts, "stale"})
|
||||
continue
|
||||
}
|
||||
cs.Samples = append(cs.Samples, [2]any{ts, encodeFloat(v)})
|
||||
}
|
||||
ds.Series = append(ds.Series, cs)
|
||||
}
|
||||
if err := ss.Err(); err != nil {
|
||||
return nil, "series-set", nil
|
||||
}
|
||||
return ds, "", nil
|
||||
}
|
||||
|
||||
// parseTestDuration accepts promqltest's time notation: a Prometheus
|
||||
// duration ("5m", "1m30s"), a bare "0", or bare seconds.
|
||||
func parseTestDuration(s string) (int64, error) {
|
||||
if d, err := model.ParseDuration(s); err == nil {
|
||||
return int64(time.Duration(d) / time.Millisecond), nil
|
||||
}
|
||||
if n, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int64(n * 1000), nil
|
||||
}
|
||||
return 0, fmt.Errorf("unparsable duration %q", s)
|
||||
}
|
||||
|
||||
func encodeFloat(f float64) any {
|
||||
switch {
|
||||
case math.IsNaN(f):
|
||||
return "NaN"
|
||||
case math.IsInf(f, 1):
|
||||
return "Inf"
|
||||
case math.IsInf(f, -1):
|
||||
return "-Inf"
|
||||
default:
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
// roundToNonZeroDecimals mirrors querybuildertypesv5's sanitizeValue rounding
|
||||
// (pkg/types/querybuildertypes/querybuildertypesv5/resp.go) so the frozen
|
||||
// expectations equal what the API emits for the same float.
|
||||
func roundToNonZeroDecimals(val float64, n int) float64 {
|
||||
if val == 0 || math.IsNaN(val) || math.IsInf(val, 0) {
|
||||
return val
|
||||
}
|
||||
absVal := math.Abs(val)
|
||||
if absVal >= 1 {
|
||||
multiplier := math.Pow(10, float64(n))
|
||||
rounded := math.Round(val*multiplier) / multiplier
|
||||
if math.IsInf(rounded, 0) {
|
||||
// Mirrors the overflow guard in querybuildertypesv5.
|
||||
return val
|
||||
}
|
||||
if rounded == math.Trunc(rounded) {
|
||||
return rounded
|
||||
}
|
||||
str := strconv.FormatFloat(rounded, 'f', -1, 64)
|
||||
result, _ := strconv.ParseFloat(str, 64)
|
||||
return result
|
||||
}
|
||||
order := math.Floor(math.Log10(absVal))
|
||||
scale := math.Pow(10, -order+float64(n)-1)
|
||||
rounded := math.Round(val*scale) / scale
|
||||
str := strconv.FormatFloat(rounded, 'f', -1, 64)
|
||||
result, _ := strconv.ParseFloat(str, 64)
|
||||
return result
|
||||
}
|
||||
|
||||
// crossCheckFileAllowlist names files whose written expectations assume
|
||||
// engine options we deliberately run differently, with the reason. Every
|
||||
// other mismatch between our engine-computed expectations and upstream's
|
||||
// hand-written ones aborts generation: the corpus must never contradict
|
||||
// the testdata it claims to represent.
|
||||
var crossCheckFileAllowlist = map[string]string{
|
||||
"name_label_dropping.test": "expectations written for EnableDelayedNameRemoval; our engine matches the server default (off)",
|
||||
}
|
||||
|
||||
// crossCheckUpstream validates the transcription chain — load parsing,
|
||||
// eval parsing, storage loading — by comparing the reference engine's raw
|
||||
// output on the base grid against the expectations upstream wrote under the
|
||||
// same eval, with upstream's own tolerance (almost.Equal, 1e-6 relative).
|
||||
// The corpus's authority is "what the reference engine computes over
|
||||
// upstream's data"; this pins that computation to upstream's own record of
|
||||
// it.
|
||||
func crossCheckUpstream(engine *promql.Engine, seriesParser parser.Parser, stor *teststorage.TestStorage, cmd command, base corpusCase, sourceFile string, skips map[string]int) error {
|
||||
type expSeries struct {
|
||||
labels labels.Labels
|
||||
points map[int64]float64
|
||||
}
|
||||
var expected []expSeries
|
||||
scalarOnly := false
|
||||
var scalarValue float64
|
||||
for _, line := range cmd.body {
|
||||
if strings.HasPrefix(line, "expect") {
|
||||
// expect fail/warn/info/ordered directives and "expect range
|
||||
// vector"/"expect string" annotations, not series expectations.
|
||||
continue
|
||||
}
|
||||
if f, err := strconv.ParseFloat(line, 64); err == nil && len(cmd.body) == 1 {
|
||||
scalarOnly, scalarValue = true, f
|
||||
break
|
||||
}
|
||||
metric, vals, err := seriesParser.ParseSeriesDesc(line)
|
||||
if err != nil {
|
||||
skips["crosscheck-skip:unparsable-expectation"]++
|
||||
return nil
|
||||
}
|
||||
points := map[int64]float64{}
|
||||
for k, v := range vals {
|
||||
if v.Histogram != nil {
|
||||
skips["crosscheck-skip:histogram-expectation"]++
|
||||
return nil
|
||||
}
|
||||
if v.Omitted {
|
||||
continue
|
||||
}
|
||||
points[base.StartMs+int64(k)*base.StepMs] = v.Value
|
||||
}
|
||||
expected = append(expected, expSeries{labels: metric, points: points})
|
||||
}
|
||||
|
||||
qry, err := engine.NewRangeQuery(context.Background(), stor, nil, base.Expr,
|
||||
time.UnixMilli(base.StartMs), time.UnixMilli(base.EndMs), time.Duration(base.StepMs)*time.Millisecond)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crosscheck parse %q: %w", base.Expr, err)
|
||||
}
|
||||
defer qry.Close()
|
||||
res := qry.Exec(context.Background())
|
||||
if res.Err != nil {
|
||||
return fmt.Errorf("crosscheck eval %q: %w", base.Expr, res.Err)
|
||||
}
|
||||
matrix, ok := res.Value.(promql.Matrix)
|
||||
if !ok {
|
||||
skips["crosscheck-skip:non-matrix"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
mismatch := func(format string, args ...any) error {
|
||||
if reason, ok := crossCheckFileAllowlist[sourceFile]; ok {
|
||||
skips["crosscheck-allowlisted:"+sourceFile]++
|
||||
_ = reason
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s:%d: corpus contradicts upstream expectation for %q: %s",
|
||||
sourceFile, cmd.line, base.Expr, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
if scalarOnly {
|
||||
if len(matrix) != 1 || matrix[0].Metric.Len() != 0 {
|
||||
return mismatch("scalar expectation but %d series", len(matrix))
|
||||
}
|
||||
if len(matrix[0].Floats) == 0 || !almost.Equal(matrix[0].Floats[len(matrix[0].Floats)-1].F, scalarValue, defaultEpsilon) {
|
||||
return mismatch("scalar %v != expected %v", matrix[0].Floats, scalarValue)
|
||||
}
|
||||
skips["crosscheck-ok"]++
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(matrix) != len(expected) {
|
||||
return mismatch("engine returned %d series, upstream wrote %d", len(matrix), len(expected))
|
||||
}
|
||||
for _, exp := range expected {
|
||||
var got *promql.Series
|
||||
for i := range matrix {
|
||||
if labels.Equal(matrix[i].Metric, exp.labels) {
|
||||
got = &matrix[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
return mismatch("series %s missing from engine result", exp.labels)
|
||||
}
|
||||
gotPoints := map[int64]float64{}
|
||||
for _, p := range got.Floats {
|
||||
gotPoints[p.T] = p.F
|
||||
}
|
||||
if len(gotPoints) != len(exp.points) {
|
||||
return mismatch("series %s: %d points, upstream wrote %d", exp.labels, len(gotPoints), len(exp.points))
|
||||
}
|
||||
for ts, want := range exp.points {
|
||||
gotV, ok := gotPoints[ts]
|
||||
if !ok {
|
||||
return mismatch("series %s: no point at %d", exp.labels, ts)
|
||||
}
|
||||
if math.IsNaN(want) && math.IsNaN(gotV) {
|
||||
continue
|
||||
}
|
||||
if !almost.Equal(gotV, want, defaultEpsilon) {
|
||||
return mismatch("series %s at %d: engine %v, upstream wrote %v", exp.labels, ts, gotV, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
skips["crosscheck-ok"]++
|
||||
return nil
|
||||
}
|
||||
103
scripts/promqltestcorpus/go.mod
Normal file
103
scripts/promqltestcorpus/go.mod
Normal file
@@ -0,0 +1,103 @@
|
||||
module github.com/SigNoz/signoz/scripts/promqltestcorpus
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/prometheus/common v0.67.5
|
||||
github.com/prometheus/prometheus v0.311.3
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dennwc/varint v1.0.0 // indirect
|
||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 // indirect
|
||||
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
|
||||
github.com/oklog/ulid/v2 v2.1.1 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_golang/exp v0.0.0-20260325093428-d8591d0db856 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/prometheus/sigv4 v0.4.1 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/goleak v1.3.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.272.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/apimachinery v0.35.3 // indirect
|
||||
k8s.io/client-go v0.35.3 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
449
scripts/promqltestcorpus/go.sum
Normal file
449
scripts/promqltestcorpus/go.sum
Normal file
@@ -0,0 +1,449 @@
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5 v5.7.0 h1:LkHbJbgF3YyvC53aqYGR+wWQDn2Rdp9AQdGndf9QvY4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5 v5.7.0/go.mod h1:QyiQdW4f4/BIfB8ZutZ2s+28RAgfa/pT+zS++ZHyM1I=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.3.0 h1:bXwSugBiSbgtz7rOtbfGf+woewp4f06orW9OP5BjHLA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.3.0/go.mod h1:Y/HgrePTmGy9HjdSGTqZNa+apUpTVIEVKXJyARP2lrk=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/Code-Hex/go-generics-cache v1.5.1 h1:6vhZGc5M7Y/YD8cIUcY8kcuQLB4cHR7U+0KMqAA0KcU=
|
||||
github.com/Code-Hex/go-generics-cache v1.5.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
|
||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
|
||||
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0 h1:98Miqj16un1WLNyM1RjVDhXYumhqZrQfAeG8i4jPG6o=
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.0/go.mod h1:T6ndRfdhnXLIY5oKBHjYZDVj706los2zGdpThppquvA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ecs v1.74.0 h1:YS5TXaEvzDb+sV+wdQFUtuCAk0GeFR9Ai6HFdxpz6q8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ecs v1.74.0/go.mod h1:10kBgdaNJz0FO/+JWDUH+0rtSjkn5yafgavDDmmhFzs=
|
||||
github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.12 h1:S066ajzfPRCSW4lsSHOYglne6SNi2CHt1u5omzW1RBg=
|
||||
github.com/aws/aws-sdk-go-v2/service/elasticache v1.51.12/go.mod h1:86SE4NcXxbxr8KTG3yOyDmd4HyiFmKl8TexXnhYJ+Bw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/kafka v1.49.1 h1:BgBatWcQIFqF1l6KGHjv66V0d/ISnWrTwxDx/Jf6EJM=
|
||||
github.com/aws/aws-sdk-go-v2/service/kafka v1.49.1/go.mod h1:pMpys+PlrN//vj8j5s0oOAMJjauj81VkHzIZxPVWOro=
|
||||
github.com/aws/aws-sdk-go-v2/service/lightsail v1.51.0 h1:cg6PxzoIide2wiEyLfikOFN+XwHafwR8p5+L9U1E8dQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/lightsail v1.51.0/go.mod h1:YvX7hjUWecrKX8fBkbEncyddEW85xjNH+u5JHioITOw=
|
||||
github.com/aws/aws-sdk-go-v2/service/rds v1.117.0 h1:T1Xe9sYxSUUQOvd1RsFeVk/IXFPdqSiN0atXu/Hy/8A=
|
||||
github.com/aws/aws-sdk-go-v2/service/rds v1.117.0/go.mod h1:QbXW4coAMakHQhf1qhE0eVVCen9gwB/Kvn+HHHKhpGY=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 h1:6df1vn4bBlDDo4tARvBm7l6KA9iVMnE3NWizDeWSrps=
|
||||
github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3/go.mod h1:CIWtjkly68+yqLPbvwwR/fjNJA/idrtULjZWh2v1ys0=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dennwc/varint v1.0.0 h1:kGNFFSSw8ToIy3obO/kKr8U9GZYUAxQEVuix4zfDWzE=
|
||||
github.com/dennwc/varint v1.0.0/go.mod h1:hnItb35rvZvJrbTALZtY/iQfDs48JKRG1RPpgziApxA=
|
||||
github.com/digitalocean/godo v1.178.0 h1:+B4xGOaoFwwwpM7TKhoyGHdmFg5eF9zDB1YfOLvNJ2E=
|
||||
github.com/digitalocean/godo v1.178.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
|
||||
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
||||
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
|
||||
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
|
||||
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
||||
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
|
||||
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
|
||||
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-zookeeper/zk v1.0.4 h1:DPzxraQx7OrPyXq2phlGlNSIyWEsAox0RJmjTseMV6I=
|
||||
github.com/go-zookeeper/zk v1.0.4/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.18.0 h1:jxP5Uuo3bxm3M6gGtV94P4lliVetoCB4Wk2x8QA86LI=
|
||||
github.com/googleapis/gax-go/v2 v2.18.0/go.mod h1:uSzZN4a356eRG985CzJ3WfbFSpqkLTjsnhWGJR6EwrE=
|
||||
github.com/gophercloud/gophercloud/v2 v2.11.1 h1:jCs4vLH8sJgRqrPzqVfWgl7uI6JnIIlsgeIRM0uHjxY=
|
||||
github.com/gophercloud/gophercloud/v2 v2.11.1/go.mod h1:Rm0YvKQ4QYX2rY9XaDKnjRzSGwlG5ge4h6ABYnmkKQM=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 h1:cLN4IBkmkYZNnk7EAJ0BHIethd+J6LqxFNw5mSiI2bM=
|
||||
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||
github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE=
|
||||
github.com/hashicorp/consul/api v1.32.1/go.mod h1:mXUWLnxftwTmDv4W3lzxYCPD199iNLLUyLfLGFJbtl4=
|
||||
github.com/hashicorp/cronexpr v1.1.3 h1:rl5IkxXN2m681EfivTlccqIryzYJSXRGRNa0xeG7NA4=
|
||||
github.com/hashicorp/cronexpr v1.1.3/go.mod h1:P4wA0KBl9C5q2hABiMO7cp6jcIg96CDh1Efb3g1PWA4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
|
||||
github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hashicorp/nomad/api v0.0.0-20260324203407-b27b0c2e019a h1:HGwfgBNl90YBiHdbzZ/+8aMxO1UL9B/yNTAXa8iB8z8=
|
||||
github.com/hashicorp/nomad/api v0.0.0-20260324203407-b27b0c2e019a/go.mod h1:KkLNLU0Nyfh5jWsFoF/PsmMbKpRIAoIV4lmQoJWgKCk=
|
||||
github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY=
|
||||
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0 h1:HlLL/aaVXUulqe+rsjoJmrxKhPi1MflL5O9iq5QEtvo=
|
||||
github.com/hetznercloud/hcloud-go/v2 v2.36.0/go.mod h1:MnN/QJEa/RYNQiiVoJjNHPntM7Z1wlYPgJ2HA40/cDE=
|
||||
github.com/ionos-cloud/sdk-go/v6 v6.3.6 h1:l/TtKgdQ1wUH3DDe2SfFD78AW+TJWdEbDpQhHkWd6CM=
|
||||
github.com/ionos-cloud/sdk-go/v6 v6.3.6/go.mod h1:nUGHP4kZHAZngCVr4v6C8nuargFrtvt7GrzH/hqn7c4=
|
||||
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
|
||||
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/providers/confmap v1.0.0 h1:mHKLJTE7iXEys6deO5p6olAiZdG5zwp8Aebir+/EaRE=
|
||||
github.com/knadh/koanf/providers/confmap v1.0.0/go.mod h1:txHYHiI2hAtF0/0sCmcuol4IDcuQbKTybiB1nOcUo1A=
|
||||
github.com/knadh/koanf/v2 v2.3.3 h1:jLJC8XCRfLC7n4F+ZKKdBsbq1bfXTpuFhf4L7t94D94=
|
||||
github.com/knadh/koanf/v2 v2.3.3/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b h1:udzkj9S/zlT5X367kqJis0QP7YMxobob6zhzq6Yre00=
|
||||
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/linode/linodego v1.66.0 h1:rK8QJFaV53LWOEJvb/evhTg/dP5ElvtuZmx4iv4RJds=
|
||||
github.com/linode/linodego v1.66.0/go.mod h1:12ykGs9qsvxE+OU3SXuW2w+DTruWF35FPlXC7gGk2tU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0 h1:CiTjQE/Hh5xK2t56ogrDK4nl0+tJPNmASCs4zEYZ/xU=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.148.0/go.mod h1:WUFkzTiOpt7EYyL67gv1GOf3RD8qKWGtin3lY9LYzW4=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0 h1:1TLg6YrS3Au6F7xw3ws2Njbwj13IMqPplvGFi+18fWs=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.148.0/go.mod h1:P8hZEDIQk4REgUWyLhSVRHwTxK6KkifKfg36BmmQ/DI=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0 h1:xgD/kNGp/wWY+bwY599Pc01OamYN17phRiTP934bM5Y=
|
||||
github.com/open-telemetry/opentelemetry-collector-contrib/processor/deltatocumulativeprocessor v0.148.0/go.mod h1:ZK7wvaefla9lB3bAW0rNKt7IzRPcTRQoOFqr4sZy/XM=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE=
|
||||
github.com/ovh/go-ovh v1.9.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_golang/exp v0.0.0-20260325093428-d8591d0db856 h1:1Y6bmpZb8peQCy1IpctnAhIFuyhrdtMaDnETChhSNns=
|
||||
github.com/prometheus/client_golang/exp v0.0.0-20260325093428-d8591d0db856/go.mod h1:Vf0QcmVhGqpjLxZOaWrFSep86vchQtJmbztFaMM4f6Q=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/prometheus/prometheus v0.311.3 h1:3IrVxQv6v5i/ZCGi6OrYeBhtCwaPTn6Z3DYruXoYm3M=
|
||||
github.com/prometheus/prometheus v0.311.3/go.mod h1:gjsCxTKtHO1Q8T9333u1s+lUR1OjPyM7ruuGH8RvVyo=
|
||||
github.com/prometheus/sigv4 v0.4.1 h1:EIc3j+8NBea9u1iV6O5ZAN8uvPq2xOIUPcqCTivHuXs=
|
||||
github.com/prometheus/sigv4 v0.4.1/go.mod h1:eu+ZbRvsc5TPiHwqh77OWuCnWK73IdkETYY46P4dXOU=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0 h1:vlSN6/CkEY0pY8KaB0yqo/pCLZvp9nhdbBdjipT4gWo=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.4.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stackitcloud/stackit-sdk-go/core v0.23.0 h1:zPrOhf3Xe47rKRs1fg/AqKYUiJJRYjdcv+3qsS50mEs=
|
||||
github.com/stackitcloud/stackit-sdk-go/core v0.23.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/vultr/govultr/v3 v3.28.1 h1:KR3LhppYARlBujY7+dcrE7YKL0Yo9qXL+msxykKQrLI=
|
||||
github.com/vultr/govultr/v3 v3.28.1/go.mod h1:2zyUw9yADQaGwKnwDesmIOlBNLrm7edsCfWHFJpWKf8=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/collector/component v1.54.0 h1:LvtX0Tzz18n44OrUFVk77N1FNsejfWJqztB28hrmDM8=
|
||||
go.opentelemetry.io/collector/component v1.54.0/go.mod h1:yUMBYsySY/sDcXm8kOzEoZxt+JLdala6hxzSW0npOxY=
|
||||
go.opentelemetry.io/collector/confmap v1.54.0 h1:RUoxQ4uAYHTI57GfHh61D00tTQsXm9T88ozrAiicByc=
|
||||
go.opentelemetry.io/collector/confmap v1.54.0/go.mod h1:mQxG8bk0IWIt9gbWMvzE+cRkOuCuzbzkNGBq2YJ4wNM=
|
||||
go.opentelemetry.io/collector/confmap/xconfmap v0.148.0 h1:UW8MX5VlKJf67x4Et7J9kPwP9Rv4VSmJ+UUpgRcb//c=
|
||||
go.opentelemetry.io/collector/confmap/xconfmap v0.148.0/go.mod h1:4qTMr3V0uSXXac9wVs/UD5fIqRKw5yIl58+Vjsc6RHM=
|
||||
go.opentelemetry.io/collector/consumer v1.54.0 h1:RGGtUN+GbkV1px3T6XdUHmgJ+ldJ1hAHdesFzW/wgL0=
|
||||
go.opentelemetry.io/collector/consumer v1.54.0/go.mod h1:1PC6XINTL9DdT1bwvfMdHE72EB4RWU/WcPemUrhqKN8=
|
||||
go.opentelemetry.io/collector/featuregate v1.54.0 h1:ufo5Hy4Co9pcHVg24hyanm8qFG3TkkYbVyQXPVAbwDc=
|
||||
go.opentelemetry.io/collector/featuregate v1.54.0/go.mod h1:PS7zY/zaCb28EqciePVwRHVhc3oKortTFXsi3I6ee4g=
|
||||
go.opentelemetry.io/collector/internal/componentalias v0.148.0 h1:Y6MftNIZSzOr47TTj6A2z2UR3IwbeG46sAQshicGtDg=
|
||||
go.opentelemetry.io/collector/internal/componentalias v0.148.0/go.mod h1:uwKzfehzwRgHxdHgFXYSBHNBeWSSqsqQYGWr5fk08G0=
|
||||
go.opentelemetry.io/collector/pdata v1.54.0 h1:3LharKb792cQ3VrUGxd3IcpWwfu3ST+GSTU382jVz1s=
|
||||
go.opentelemetry.io/collector/pdata v1.54.0/go.mod h1:+MqC3VVOv/EX9YVFUo+mI4F0YmwJ+fXBYwjmu+mRiZ8=
|
||||
go.opentelemetry.io/collector/pipeline v1.54.0 h1:jYlCkdFLITVBdeB+IGS07zXWywEgvT3Ky46vdKKT+Ks=
|
||||
go.opentelemetry.io/collector/pipeline v1.54.0/go.mod h1:RD90NG3Jbk965Xaqym3JyHkuol4uZJjQVUkD9ddXJIs=
|
||||
go.opentelemetry.io/collector/processor v1.54.0 h1:zmHBFiEFmU9ZYuHhVP3lHIkbfy+ueapzGpTdXVMcWBg=
|
||||
go.opentelemetry.io/collector/processor v1.54.0/go.mod h1:L0lA6DZ0VbrtQBg44cmYfSpRlgm4zxW1I6QfBnRizPw=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0 h1:c9r/G1CSw4dPI1jaNNG9RnQP+q4SvZnHciDQJVIvchU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.67.0/go.mod h1:gO9smoZe9KnZcJCqcB0lMmQ4Z5VEifYmjMTpnwtTSuQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA=
|
||||
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA=
|
||||
google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d h1:vsOm753cOAMkt76efriTCDKjpCbK18XGHMJHo0JUKhc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c h1:xgCzyF2LFIO/0X2UAoVRiXKU5Xg6VjToG4i2/ecSswk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
|
||||
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ=
|
||||
k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4=
|
||||
k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8=
|
||||
k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.3 h1:s1lZbpN4uI6IxeTM2cpdtrwHcSOBML1ODNTCCfsP1pg=
|
||||
k8s.io/client-go v0.35.3/go.mod h1:RzoXkc0mzpWIDvBrRnD+VlfXP+lRzqQjCmKtiwZ8Q9c=
|
||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
119
scripts/promqltestcorpus/main.go
Normal file
119
scripts/promqltestcorpus/main.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Command promqltestcorpus extracts an absolute-truth conformance corpus from
|
||||
// the upstream Prometheus promqltest testdata scripts.
|
||||
//
|
||||
// The integration suites compare our PromQL serving paths against each other
|
||||
// (parity) or against nothing (smoke); both are blind to a bug that moves the
|
||||
// oracle — anything that changes what the engine is fed. This corpus is the
|
||||
// third leg: the samples come from upstream's own load notation (parsed by
|
||||
// upstream's parser), the expected outputs are computed by the vendored
|
||||
// reference engine over those samples, and both are frozen to JSON. The
|
||||
// Python suite tests/integration/tests/promqlconformance replays ingestion
|
||||
// and asserts API responses against the frozen expectations — an oracle that
|
||||
// does not move when the querier or the transpiler changes.
|
||||
//
|
||||
// Regenerate (after bumping the vendored Prometheus) with:
|
||||
//
|
||||
// cd scripts/promqltestcorpus && go run . \
|
||||
// -out ../../tests/integration/testdata/promqltestcorpus/corpus.json
|
||||
//
|
||||
// upstream.go holds verbatim copies of upstream's private .test-format
|
||||
// parsing; refresh it against promql/promqltest/test.go on every version
|
||||
// bump. Drift fails loudly: the generator parses the NEW module's testdata,
|
||||
// so unknown syntax surfaces here, and regeneration is already a mandatory
|
||||
// step of any bump.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/prometheus/prometheus/promql/promqltest"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", os.Getenv("PROMQLTEST_CORPUS_OUT"), "path to write corpus.json")
|
||||
flag.Parse()
|
||||
if *out == "" {
|
||||
log.Fatal("set -out (or PROMQLTEST_CORPUS_OUT) to the corpus destination")
|
||||
}
|
||||
if err := run(*out); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(out string) error {
|
||||
promDir, promVersion, err := prometheusModule()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
testdataDir := filepath.Join(promDir, "promql", "promqltest", "testdata")
|
||||
files, err := filepath.Glob(filepath.Join(testdataDir, "*.test"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no .test files under %s", testdataDir)
|
||||
}
|
||||
|
||||
// NewTestEngine's options minus EnableDelayedNameRemoval: upstream's
|
||||
// testdata assumes that feature, but the Prometheus server default and
|
||||
// our engine (pkg/prometheus/engine.go) run with it off — the oracle
|
||||
// must model the semantics we serve.
|
||||
engine := promql.NewEngine(promql.EngineOpts{
|
||||
MaxSamples: maxSamples,
|
||||
Timeout: 100 * time.Second,
|
||||
NoStepSubqueryIntervalFn: func(int64) int64 { return time.Minute.Milliseconds() },
|
||||
EnableAtModifier: true,
|
||||
EnableNegativeOffset: true,
|
||||
LookbackDelta: lookbackMs * time.Millisecond,
|
||||
Parser: parser.NewParser(promqltest.TestParserOpts),
|
||||
})
|
||||
defer func() { _ = engine.Close() }()
|
||||
seriesParser := parser.NewParser(promqltest.TestParserOpts)
|
||||
// Standard options: an expression the server-side parser would reject is
|
||||
// not servable, so it must not enter the corpus.
|
||||
exprParser := parser.NewParser(parser.Options{})
|
||||
|
||||
c, skips, err := generate(files, engine, seriesParser, exprParser, promVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCorpus(out, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("wrote %s: %d cases over %d datasets", out, len(c.Cases), len(c.Datasets))
|
||||
keys := make([]string, 0, len(skips))
|
||||
for k := range skips {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
log.Printf("skipped %5d %s", skips[k], k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// prometheusModule locates the vendored prometheus module in the module
|
||||
// cache; the generator always parses the testdata of the version this
|
||||
// module requires, so a version bump regenerates against the new scripts.
|
||||
func prometheusModule() (dir, version string, err error) {
|
||||
outDir, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/prometheus/prometheus").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("locating prometheus module: %w", err)
|
||||
}
|
||||
outVer, err := exec.Command("go", "list", "-m", "-f", "{{.Version}}", "github.com/prometheus/prometheus").Output()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolving prometheus version: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(outDir)), strings.TrimSpace(string(outVer)), nil
|
||||
}
|
||||
163
scripts/promqltestcorpus/upstream.go
Normal file
163
scripts/promqltestcorpus/upstream.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// This file carries the upstream promqltest .test-format knowledge this
|
||||
// generator depends on. The patterns are verbatim copies of unexported
|
||||
// definitions in prometheus@v0.311.3 promql/promqltest/test.go (upstream
|
||||
// exposes no public API for parsing the format short of running assertions
|
||||
// through a testing.TB); the loader is adapted from loadCmd.set/append in
|
||||
// the same file. REFRESH THIS FILE against test.go on every prometheus
|
||||
// version bump.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/util/teststorage"
|
||||
)
|
||||
|
||||
// Copied verbatim from promql/promqltest/test.go (prometheus@v0.311.3).
|
||||
var (
|
||||
patLoad = regexp.MustCompile(`^load(?:_(with_nhcb))?\s+(.+?)$`)
|
||||
patEvalInstant = regexp.MustCompile(`^eval(?:_(fail|warn|ordered|info))?\s+instant\s+(?:at\s+(.+?))?\s+(.+)$`)
|
||||
patEvalRange = regexp.MustCompile(`^eval(?:_(fail|warn|info))?\s+range\s+from\s+(.+)\s+to\s+(.+)\s+step\s+(.+?)\s+(.+)$`)
|
||||
patExpect = regexp.MustCompile(`^expect\s+(ordered|fail|warn|no_warn|info|no_info)(?:\s+(regex|msg):(.+))?$`)
|
||||
)
|
||||
|
||||
// testStartTime is upstream's epoch for all load offsets (test.go).
|
||||
var testStartTime = time.Unix(0, 0).UTC()
|
||||
|
||||
// command is one column-0 block of a .test script with its attached
|
||||
// continuation lines.
|
||||
type command struct {
|
||||
kind string // "load" | "eval" | "clear" | "skip"
|
||||
head string
|
||||
body []string
|
||||
line int
|
||||
}
|
||||
|
||||
// parseScript tokenizes a .test script into column-0 commands with their
|
||||
// indented lines, classifying heads with upstream's own patterns (line
|
||||
// walking follows (*test).parse in test.go: blank lines and #-comments
|
||||
// reset, indentation attaches). eval_fail / eval_warn / eval_info /
|
||||
// eval_ordered assert errors, warnings or ordering — none of which cross
|
||||
// the API comparably — so their modifier forms are skipped.
|
||||
func parseScript(script string) []command {
|
||||
var cmds []command
|
||||
var cur *command
|
||||
for i, line := range strings.Split(script, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
cur = nil
|
||||
continue
|
||||
}
|
||||
isTop := line[0] != ' ' && line[0] != '\t'
|
||||
if !isTop {
|
||||
if cur != nil {
|
||||
cur.body = append(cur.body, trimmed)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case trimmed == "clear":
|
||||
cmds = append(cmds, command{kind: "clear", line: i + 1})
|
||||
cur = nil
|
||||
case patLoad.MatchString(trimmed):
|
||||
// load_with_nhcb stays kind "load": checkLoad rejects the
|
||||
// variant, which poisons the whole segment — evals over a
|
||||
// partially-loaded dataset must not enter the corpus.
|
||||
cmds = append(cmds, command{kind: "load", head: trimmed, line: i + 1})
|
||||
cur = &cmds[len(cmds)-1]
|
||||
case patEvalInstant.MatchString(trimmed):
|
||||
if m := patEvalInstant.FindStringSubmatch(trimmed); m[1] != "" {
|
||||
cmds = append(cmds, command{kind: "skip", head: "eval_" + m[1], line: i + 1})
|
||||
cur = nil
|
||||
break
|
||||
}
|
||||
cmds = append(cmds, command{kind: "eval", head: trimmed, line: i + 1})
|
||||
cur = &cmds[len(cmds)-1]
|
||||
case patEvalRange.MatchString(trimmed):
|
||||
if m := patEvalRange.FindStringSubmatch(trimmed); m[1] != "" {
|
||||
cmds = append(cmds, command{kind: "skip", head: "eval_" + m[1], line: i + 1})
|
||||
cur = nil
|
||||
break
|
||||
}
|
||||
cmds = append(cmds, command{kind: "eval", head: trimmed, line: i + 1})
|
||||
cur = &cmds[len(cmds)-1]
|
||||
default:
|
||||
cmds = append(cmds, command{kind: "skip", head: strings.Fields(trimmed)[0], line: i + 1})
|
||||
cur = nil
|
||||
}
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
// loadSeriesStorage builds a TSDB with the load blocks' samples, adapted
|
||||
// from loadCmd.set/append (test.go): each series' samples sit at
|
||||
// testStartTime + i*gap, omitted values leave gaps, and — like loadCmd.set's
|
||||
// hash-keyed defs map — a series redefined within one load block replaces
|
||||
// its earlier definition entirely (upstream testdata relies on this:
|
||||
// aggregators.test defines data{test="inf3",point="d"} twice). Only float
|
||||
// samples are supported; checkLoad guarantees no histogram series reach
|
||||
// here.
|
||||
func loadSeriesStorage(seriesParser seriesDescParser, loads []command) (*teststorage.TestStorage, error) {
|
||||
type def struct {
|
||||
metric labels.Labels
|
||||
samples []promql.Sample
|
||||
}
|
||||
defs := map[uint64]def{}
|
||||
var order []uint64
|
||||
for _, l := range loads {
|
||||
fields := strings.Fields(l.head)
|
||||
gapDur, err := model.ParseDuration(fields[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load interval %q: %w", fields[1], err)
|
||||
}
|
||||
gap := time.Duration(gapDur)
|
||||
for _, line := range l.body {
|
||||
metric, vals, err := seriesParser.ParseSeriesDesc(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("series %q: %w", line, err)
|
||||
}
|
||||
samples := make([]promql.Sample, 0, len(vals))
|
||||
ts := testStartTime
|
||||
for _, v := range vals {
|
||||
if !v.Omitted {
|
||||
samples = append(samples, promql.Sample{T: ts.UnixMilli(), F: v.Value})
|
||||
}
|
||||
ts = ts.Add(gap)
|
||||
}
|
||||
h := metric.Hash()
|
||||
if _, seen := defs[h]; !seen {
|
||||
order = append(order, h)
|
||||
}
|
||||
defs[h] = def{metric: metric, samples: samples}
|
||||
}
|
||||
}
|
||||
|
||||
stor, err := teststorage.NewWithError()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app := stor.Appender(context.Background())
|
||||
for _, h := range order {
|
||||
d := defs[h]
|
||||
for _, s := range d.samples {
|
||||
if _, err := app.Append(0, d.metric, s.T, s.F); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := app.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stor, nil
|
||||
}
|
||||
|
||||
// defaultEpsilon is upstream's relative tolerance for sample values
|
||||
// (promql/promqltest/test.go).
|
||||
const defaultEpsilon = 0.000001
|
||||
12
tests/fixtures/metrics.py
vendored
12
tests/fixtures/metrics.py
vendored
@@ -687,11 +687,17 @@ def insert_metrics_to_clickhouse(conn, metrics: list[Metrics]) -> None:
|
||||
Pure function so the seeder container can reuse the exact insert path
|
||||
used by the pytest fixture. `conn` is a clickhouse-connect Client.
|
||||
"""
|
||||
time_series_map: dict[int, MetricsTimeSeries] = {}
|
||||
# One registration row per (series, hour bucket), unix_milli floored to
|
||||
# the hour — the exporter's exact shape. Readers floor lookup windows to
|
||||
# these buckets: skipping per-bucket re-registration or keeping raw
|
||||
# mid-hour timestamps hides series in ways production never sees.
|
||||
time_series_map: dict[tuple[int, int], MetricsTimeSeries] = {}
|
||||
for metric in metrics:
|
||||
fp = int(metric.time_series.fingerprint)
|
||||
if fp not in time_series_map:
|
||||
time_series_map[fp] = metric.time_series
|
||||
hour_bucket = int(metric.time_series.unix_milli) // 3_600_000
|
||||
if (fp, hour_bucket) not in time_series_map:
|
||||
metric.time_series.unix_milli = np.int64(hour_bucket * 3_600_000)
|
||||
time_series_map[(fp, hour_bucket)] = metric.time_series
|
||||
|
||||
if len(time_series_map) > 0:
|
||||
conn.insert(
|
||||
|
||||
130231
tests/integration/testdata/promqltestcorpus/corpus.json
vendored
Normal file
130231
tests/integration/testdata/promqltestcorpus/corpus.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
4
tests/integration/testdata/promqltestcorpus/known_divergences.json
vendored
Normal file
4
tests/integration/testdata/promqltestcorpus/known_divergences.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"note": "Divergences of the CURRENT promql serving path from the upstream reference engine, enforced exactly by 01_upstream_corpus.py in both directions. These document shipped defects, not test debt: the dominant class is the v1 remote-read fetch injecting a synthetic 'fingerprint' label into every series (pkg/prometheus/clickhouseprometheus/json.go), which breaks without() grouping and default vector matching. Entries must be REMOVED as the serving path is fixed or swapped.",
|
||||
"divergences": {}
|
||||
}
|
||||
208
tests/integration/tests/promqlconformance/01_upstream_corpus.py
Normal file
208
tests/integration/tests/promqlconformance/01_upstream_corpus.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
Upstream promqltest conformance: replay the frozen corpus extracted from
|
||||
Prometheus' own promql/promqltest testdata and assert our API returns the
|
||||
reference engine's answers.
|
||||
|
||||
Unlike the parity suites, the oracle here is a committed file
|
||||
(tests/integration/testdata/promqltestcorpus/corpus.json), generated by
|
||||
scripts/promqltestcorpus from upstream's load scripts and the vendored
|
||||
reference engine. It therefore keeps working when the serving path itself is
|
||||
the thing being changed — the one situation where comparing two live paths
|
||||
against each other is blind.
|
||||
|
||||
Datasets are placed on disjoint time windows (2h isolation gaps, far beyond
|
||||
the 5m lookback) so one bulk ingest serves every case without cross-talk.
|
||||
Expected values carry the API's 3-significant-decimal rounding, mirrored by
|
||||
the generator; comparison allows one rounding quantum for ULP-at-boundary
|
||||
noise between storage iteration orders.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import get_all_series, make_query_request
|
||||
|
||||
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
|
||||
CORPUS_FILE = os.path.join(TESTDATA_DIR, "promqltestcorpus", "corpus.json")
|
||||
KNOWN_DIVERGENCES_FILE = os.path.join(TESTDATA_DIR, "promqltestcorpus", "known_divergences.json")
|
||||
|
||||
ISOLATION_GAP_MS = 2 * 3600 * 1000
|
||||
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
|
||||
|
||||
|
||||
def _values_close(a: float, b: float) -> bool:
|
||||
if math.isnan(a) or math.isnan(b):
|
||||
return math.isnan(a) and math.isnan(b)
|
||||
if math.isinf(a) or math.isinf(b):
|
||||
return a == b
|
||||
if a == b:
|
||||
return True
|
||||
# Both sides carry the API's rounding (>=1: three decimal places; <1:
|
||||
# three significant digits). A true value sitting exactly on a rounding
|
||||
# boundary can round either way when the two computations differ at ULP
|
||||
# level (float aggregation order over series is storage-iteration
|
||||
# dependent), so allow one rounding quantum.
|
||||
scale = max(abs(a), abs(b))
|
||||
if scale >= 1:
|
||||
# Values too large to round pass through unrounded; give those an
|
||||
# ULP-class relative grace on top of the rounding quantum.
|
||||
quantum = max(1e-3, scale * 1e-9)
|
||||
else:
|
||||
quantum = 10 ** (math.floor(math.log10(scale)) - 2)
|
||||
return abs(a - b) <= quantum + 1e-12
|
||||
|
||||
|
||||
def _response_series(data: dict) -> tuple[dict[tuple, dict[int, float]], list[tuple]]:
|
||||
"""Returns (series map, duplicate labelsets). A response carrying several
|
||||
series with identical visible labels is itself a defect signal (e.g. a
|
||||
hidden grouping label stripped on the way out) and must not be silently
|
||||
collapsed into one entry."""
|
||||
out: dict[tuple, dict[int, float]] = {}
|
||||
duplicates: list[tuple] = []
|
||||
# Empty results serialize with null aggregations/series/values fields.
|
||||
for series in get_all_series(data, "A") or []:
|
||||
lbls = {l["key"]["name"]: str(l["value"]) for l in series.get("labels") or []}
|
||||
points = {int(v["timestamp"]): SPECIALS[v["value"]] if isinstance(v["value"], str) else float(v["value"]) for v in series.get("values") or []}
|
||||
key = tuple(sorted(lbls.items()))
|
||||
if key in out:
|
||||
duplicates.append(key)
|
||||
out[key] = points
|
||||
return out, duplicates
|
||||
|
||||
|
||||
def test_upstream_promqltest_corpus(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
with open(CORPUS_FILE, encoding="utf-8") as f:
|
||||
corpus = json.load(f)
|
||||
|
||||
cases_by_dataset: dict[int, list[dict]] = {}
|
||||
for case in corpus["cases"]:
|
||||
cases_by_dataset.setdefault(case["dataset"], []).append(case)
|
||||
|
||||
# Lay datasets end to end on the timeline, newest last, ending safely in
|
||||
# the past; spans are per-dataset so the whole corpus stays within days.
|
||||
spans = {}
|
||||
for ds in corpus["datasets"]:
|
||||
sample_max = max((s["samples"][-1][0] for s in ds["series"] if s["samples"]), default=0)
|
||||
case_max = max((c["end_ms"] for c in cases_by_dataset.get(ds["id"], [])), default=0)
|
||||
spans[ds["id"]] = max(sample_max, case_max) + corpus["meta"]["lookback_ms"]
|
||||
|
||||
# Hour-aligned dataset bases: registration rows are hour-bucketed, so
|
||||
# behavior depends on where samples fall relative to hour boundaries —
|
||||
# the exact known-divergences enforcement needs that identical every run.
|
||||
hour_ms = 3_600_000
|
||||
advances = {ds["id"]: -(-(spans[ds["id"]] + ISOLATION_GAP_MS) // hour_ms) * hour_ms for ds in corpus["datasets"]}
|
||||
total = sum(advances.values())
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
cursor = (int((now - timedelta(hours=1)).timestamp() * 1000) - total) // hour_ms * hour_ms
|
||||
|
||||
bases: dict[int, int] = {}
|
||||
metrics: list[Metrics] = []
|
||||
for ds in corpus["datasets"]:
|
||||
bases[ds["id"]] = cursor
|
||||
for series in ds["series"]:
|
||||
labels = dict(series["labels"])
|
||||
metric_name = labels.pop("__name__")
|
||||
for off_ms, raw in series["samples"]:
|
||||
stale = raw == "stale"
|
||||
metrics.append(
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels=labels,
|
||||
timestamp=datetime.fromtimestamp((cursor + off_ms) / 1000, tz=UTC),
|
||||
value=0.0 if stale else (SPECIALS[raw] if isinstance(raw, str) else float(raw)),
|
||||
flags=1 if stale else 0,
|
||||
)
|
||||
)
|
||||
cursor += advances[ds["id"]]
|
||||
|
||||
insert_metrics(metrics)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
failures: list[str] = []
|
||||
for case in corpus["cases"]:
|
||||
base = bases[case["dataset"]]
|
||||
start_ms = base + case["start_ms"]
|
||||
end_ms = base + case["end_ms"]
|
||||
step_s = max(1, case["step_ms"] // 1000)
|
||||
req_start_ms = start_ms
|
||||
if case["instant"]:
|
||||
# The API rejects start == end; ask for one extra step backward
|
||||
# and compare only at the instant timestamp. Nudging the start
|
||||
# earlier instead of the end later keeps every window that the
|
||||
# expected values were computed from untouched.
|
||||
req_start_ms = start_ms - step_s * 1000
|
||||
query = {
|
||||
"type": "promql",
|
||||
"spec": {"name": "A", "query": case["expr"], "step": step_s},
|
||||
}
|
||||
|
||||
case_id = f"{case['source']}[{case['variant']}]"
|
||||
response = make_query_request(signoz, token, req_start_ms, end_ms, [query])
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
failures.append(f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}")
|
||||
continue
|
||||
|
||||
actual, duplicates = _response_series(response.json())
|
||||
if duplicates:
|
||||
failures.append(f"{case_id}: response carries multiple series with identical labels for {case['expr']!r}: {[dict(d) for d in duplicates[:3]]}")
|
||||
continue
|
||||
if case["instant"]:
|
||||
# Keep only the instant point; the extra grid step is a request
|
||||
# encoding byproduct, not part of the assertion.
|
||||
actual = {lset: {ts: v for ts, v in pts.items() if ts == end_ms} for lset, pts in actual.items()}
|
||||
actual = {lset: pts for lset, pts in actual.items() if pts}
|
||||
expected: dict[tuple, dict[int, float]] = {}
|
||||
for res in case["expected"]:
|
||||
points = {base + off_ms: SPECIALS[v] if isinstance(v, str) else float(v) for off_ms, v in res["points"]}
|
||||
expected[tuple(sorted(res["labels"].items()))] = points
|
||||
|
||||
if set(actual) != set(expected):
|
||||
missing = set(expected) - set(actual)
|
||||
extra = set(actual) - set(expected)
|
||||
failures.append(f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]}) actual={[(dict(k), {t - base: v for t, v in pts.items()}) for k, pts in actual.items()]}")
|
||||
continue
|
||||
|
||||
for lset, exp_points in expected.items():
|
||||
act_points = actual[lset]
|
||||
if set(act_points) != set(exp_points):
|
||||
failures.append(f"{case_id}: timestamp mismatch for {case['expr']!r} series {dict(lset)} (expected {len(exp_points)} points, got {len(act_points)})")
|
||||
break
|
||||
for ts, exp_v in exp_points.items():
|
||||
if not _values_close(act_points[ts], exp_v):
|
||||
failures.append(f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_points[ts]}")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
for f_line in failures:
|
||||
print("DIVERGED", f_line)
|
||||
|
||||
# Known divergences are defects of the current serving path, frozen with
|
||||
# reasons. The set is enforced exactly in both directions: a NEW
|
||||
# divergence is a regression, and a known divergence that starts passing
|
||||
# must be removed from the file — that is the ledger the serving-path
|
||||
# swap is measured against.
|
||||
known: dict[str, str] = {}
|
||||
if os.path.exists(KNOWN_DIVERGENCES_FILE):
|
||||
with open(KNOWN_DIVERGENCES_FILE, encoding="utf-8") as f:
|
||||
known = json.load(f)["divergences"]
|
||||
|
||||
failed_ids = {f_line.split(": ", 1)[0] for f_line in failures}
|
||||
unexpected = [f_line for f_line in failures if f_line.split(": ", 1)[0] not in known]
|
||||
now_passing = sorted(set(known) - failed_ids)
|
||||
|
||||
assert not unexpected, f"{len(unexpected)} corpus cases diverged beyond the known set:\n" + "\n".join(unexpected[:25])
|
||||
assert not now_passing, f"{len(now_passing)} known divergences now pass — remove them from known_divergences.json: {now_passing[:25]}"
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Regression tests for series identity in the PromQL serving path (PR #8563).
|
||||
|
||||
#8563 fixed a real duplicate-labelset collision by injecting a synthetic
|
||||
per-series "fingerprint" label, which silently broke without() grouping and
|
||||
unaggregated vector matching; the adapter now merges fingerprints sharing a
|
||||
labelset instead. Pinned here:
|
||||
|
||||
1. Clean data: without() yields exactly the grouped series with correct
|
||||
sums; "fingerprint" behaves as any absent label.
|
||||
2. The #8563 incident: one series under two fingerprints (empty-valued vs
|
||||
absent label). Both must come back as ONE merged series — not a
|
||||
"duplicate series" error, not duplicate identical-labeled output.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import get_all_series, make_query_request
|
||||
|
||||
METRIC = "probe_requests"
|
||||
EVOLVED_METRIC = "probe_schema_evolution"
|
||||
|
||||
|
||||
def _value_at(view_entry: tuple[dict, list], ts_ms: int) -> float:
|
||||
for ts, v in view_entry[1]:
|
||||
if ts == ts_ms:
|
||||
return float(v)
|
||||
raise AssertionError(f"no point at {ts_ms} in {view_entry}")
|
||||
|
||||
|
||||
def test_identical_labelsets_merge_and_grouping(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
base = now - timedelta(minutes=30)
|
||||
|
||||
# Scenario 1: four clean series, 2 groups x 2 instances, 3 samples each.
|
||||
labelsets = [
|
||||
{"group": "canary", "instance": "0"},
|
||||
{"group": "canary", "instance": "1"},
|
||||
{"group": "production", "instance": "0"},
|
||||
{"group": "production", "instance": "1"},
|
||||
]
|
||||
metrics: list[Metrics] = []
|
||||
for i, lbls in enumerate(labelsets):
|
||||
for k in range(3):
|
||||
metrics.append(
|
||||
Metrics(
|
||||
metric_name=METRIC,
|
||||
labels=dict(lbls),
|
||||
timestamp=base + timedelta(minutes=k),
|
||||
value=float((i + 1) * 100 + k),
|
||||
)
|
||||
)
|
||||
|
||||
# Scenario 2 (PR #8563): one conceptual series under two fingerprints.
|
||||
# The first three samples carry schema_url="" (empty value, dropped at
|
||||
# read time); the next three drop the label entirely (new fingerprint).
|
||||
for k in range(3):
|
||||
metrics.append(
|
||||
Metrics(
|
||||
metric_name=EVOLVED_METRIC,
|
||||
labels={"job": "api", "schema_url": ""},
|
||||
timestamp=base + timedelta(minutes=k),
|
||||
value=float(k + 1),
|
||||
)
|
||||
)
|
||||
for k in range(3, 6):
|
||||
metrics.append(
|
||||
Metrics(
|
||||
metric_name=EVOLVED_METRIC,
|
||||
labels={"job": "api"},
|
||||
timestamp=base + timedelta(minutes=k),
|
||||
value=float(k + 1),
|
||||
)
|
||||
)
|
||||
insert_metrics(metrics)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
def run(promql: str, start_ms: int, end_ms: int) -> list[tuple[dict, list]]:
|
||||
q = {"type": "promql", "spec": {"name": "A", "query": promql, "step": 60}}
|
||||
resp = make_query_request(signoz, token, start_ms, end_ms, [q])
|
||||
assert resp.status_code == HTTPStatus.OK, f"{promql!r}: {resp.text[:300]}"
|
||||
out = []
|
||||
for series in get_all_series(resp.json(), "A") or []:
|
||||
lbls = {l["key"]["name"]: str(l["value"]) for l in series.get("labels") or []}
|
||||
vals = [(v["timestamp"], v["value"]) for v in series.get("values") or []]
|
||||
out.append((lbls, vals))
|
||||
return sorted(out, key=lambda x: sorted(x[0].items()))
|
||||
|
||||
end_ms = int((base + timedelta(minutes=2)).timestamp() * 1000)
|
||||
start_ms = end_ms - 60_000
|
||||
|
||||
raw = run(METRIC, start_ms, end_ms)
|
||||
assert len(raw) == 4, f"raw selector must show the 4 ingested series: {raw}"
|
||||
assert len({tuple(sorted(l.items())) for l, _ in raw}) == 4
|
||||
assert not any("fingerprint" in l for l, _ in raw), "no synthetic fingerprint label may appear in results"
|
||||
|
||||
count = run(f"count({METRIC})", start_ms, end_ms)
|
||||
assert count and _value_at(count[0], end_ms) == 4
|
||||
|
||||
# without(instance): exactly one series per group, with the group sums —
|
||||
# not per-fingerprint groups collapsing into duplicate labelsets.
|
||||
without = run(f"sum without (instance) ({METRIC})", start_ms, end_ms)
|
||||
assert [(l.get("group"), _value_at((l, v), end_ms)) for l, v in without] == [
|
||||
("canary", 304.0),
|
||||
("production", 704.0),
|
||||
], f"without(instance) must yield 2 correctly-summed groups: {without}"
|
||||
|
||||
# "fingerprint" is now just an absent label: adding it to without() must
|
||||
# not change the result, and grouping by it collapses everything.
|
||||
healed = run(f"sum without (instance, fingerprint) ({METRIC})", start_ms, end_ms)
|
||||
assert [(l.get("group"), _value_at((l, v), end_ms)) for l, v in healed] == [
|
||||
("canary", 304.0),
|
||||
("production", 704.0),
|
||||
], f"without(instance, fingerprint) must equal without(instance): {healed}"
|
||||
|
||||
by_fp = run(f"sum by (fingerprint) ({METRIC})", start_ms, end_ms)
|
||||
assert len(by_fp) == 1 and _value_at(by_fp[0], end_ms) == 304.0 + 704.0, f"by(fingerprint) must collapse to one group (label absent): {by_fp}"
|
||||
assert "fingerprint" not in by_fp[0][0] or by_fp[0][0] == {}, by_fp
|
||||
|
||||
# Scenario 2: both fingerprints must come back as ONE merged series
|
||||
# spanning the full range — no duplicate-series error, no duplicate
|
||||
# identical-labeled output.
|
||||
evo_start_ms = int(base.timestamp() * 1000)
|
||||
evo_end_ms = int((base + timedelta(minutes=5)).timestamp() * 1000)
|
||||
evolved = run(EVOLVED_METRIC, evo_start_ms, evo_end_ms)
|
||||
assert len(evolved) == 1, f"label-evolution fingerprints must merge into one series: {evolved}"
|
||||
lbls, _ = evolved[0]
|
||||
assert lbls == {"__name__": EVOLVED_METRIC, "job": "api"}, evolved
|
||||
got = [_value_at(evolved[0], evo_start_ms + m * 60_000) for m in range(6)]
|
||||
assert got == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], f"merged series must carry both fingerprints' samples in order: {got}"
|
||||
@@ -25,9 +25,16 @@ def test_histogram_p90_returns_warning_outside_data_window(
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
metric_name = "test_p90_last_seen_bucket"
|
||||
|
||||
# Registration rows are written per (series, hour bucket) with
|
||||
# hour-floored timestamps (the exporter's shape), and metadata lookups
|
||||
# floor their window start to the hour. Data must therefore end a couple
|
||||
# of hours back for the last-15m window to be genuinely outside every
|
||||
# registration bucket; data merely 30 minutes stale shares an hour
|
||||
# bucket with the floored window and does not warn (matching
|
||||
# production behavior).
|
||||
metrics = Metrics.load_from_file(
|
||||
HISTOGRAM_FILE,
|
||||
base_time=now - timedelta(minutes=90),
|
||||
base_time=now - timedelta(hours=3),
|
||||
metric_name_override=metric_name,
|
||||
)
|
||||
insert_metrics(metrics)
|
||||
@@ -42,8 +49,8 @@ def test_histogram_p90_returns_warning_outside_data_window(
|
||||
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
|
||||
response = make_query_request(signoz, token, start_2h, end_ms, [query])
|
||||
start_4h = int((now - timedelta(hours=4)).timestamp() * 1000)
|
||||
response = make_query_request(signoz, token, start_4h, end_ms, [query])
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user