mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-22 14:10:30 +01:00
Compare commits
1 Commits
fix/genera
...
test/promq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6870d74dc |
21
.github/workflows/goci.yaml
vendored
21
.github/workflows/goci.yaml
vendored
@@ -23,6 +23,27 @@ jobs:
|
||||
PRIMUS_REF: main
|
||||
GO_TEST_CONTEXT: ./...
|
||||
GO_VERSION: 1.24
|
||||
promqlcorpus:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.24
|
||||
cache-dependency-path: scripts/promqltestcorpus/go.sum
|
||||
# The committed corpus must equal what the generator produces from the
|
||||
# vendored prometheus testdata — a version bump or generator change
|
||||
# without a regenerated corpus fails here. Regenerate with:
|
||||
# cd scripts/promqltestcorpus && go run . -out ../../tests/integration/testdata/promqltestcorpus/corpus.json
|
||||
- name: verify corpus freshness
|
||||
working-directory: scripts/promqltestcorpus
|
||||
run: |
|
||||
go run . -out /tmp/corpus-regen.json
|
||||
diff -q /tmp/corpus-regen.json ../../tests/integration/testdata/promqltestcorpus/corpus.json
|
||||
fmt:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -54,6 +54,7 @@ jobs:
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
@@ -9,11 +9,6 @@ global:
|
||||
# the path component (e.g. /signoz in https://example.com/signoz) is used
|
||||
# as the base path for all HTTP routes (both API and web frontend).
|
||||
external_url: <unset>
|
||||
# origins (scheme://host[:port], no path) allowed as login redirect targets. include
|
||||
# the origin the signoz ui is served on. when not configured, redirect targets are
|
||||
# not validated.
|
||||
# allowed_origins:
|
||||
# - https://signoz.example.com
|
||||
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
|
||||
ingestion_url: <unset>
|
||||
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
|
||||
|
||||
@@ -223,12 +223,17 @@ func (provider *provider) Update(ctx context.Context, orgID valuer.UUID, updated
|
||||
return err
|
||||
}
|
||||
|
||||
desiredTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, updatedRole.TransactionGroups)
|
||||
existingGroups := authtypes.MustNewTransactionGroupsFromTuples(existingTuples)
|
||||
additions, deletions := existingGroups.Diff(updatedRole.TransactionGroups)
|
||||
additionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, additions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
additionTuples, deletionTuples := authtypes.DiffTuples(existingTuples, desiredTuples)
|
||||
deletionTuples, err := authtypes.NewTuplesFromTransactionGroups(existingRole.Name, orgID, deletions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = provider.Write(ctx, additionTuples, deletionTuples)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,29 +2,6 @@ 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>,
|
||||
@@ -33,29 +10,15 @@ 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: 'UPSTREAM_UNAVAILABLE',
|
||||
message: error.message || 'Something went wrong',
|
||||
url: '',
|
||||
errors: [],
|
||||
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 ?? '',
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -99,7 +62,9 @@ export function convertToApiError(
|
||||
return new APIError({
|
||||
httpStatusCode: response?.status || error.status || 500,
|
||||
error: {
|
||||
code: errorData?.code || 'UPSTREAM_UNAVAILABLE',
|
||||
code:
|
||||
errorData?.code ||
|
||||
String(response?.status || error.code || 'unknown_error'),
|
||||
message:
|
||||
errorData?.message ||
|
||||
response?.statusText ||
|
||||
|
||||
@@ -2,38 +2,19 @@ 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: 'UPSTREAM_UNAVAILABLE',
|
||||
message: error.message || 'Something went wrong',
|
||||
url: '',
|
||||
errors: [],
|
||||
code: response.data.error.code,
|
||||
message: response.data.error.message,
|
||||
url: response.data.error.url,
|
||||
errors: response.data.error.errors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import { Globe, Inbox, SquarePen } from '@signozhq/icons';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
import ShareURLModal, { type ShareURLExtraOption } from './ShareURLModal';
|
||||
import ShareURLModal from './ShareURLModal';
|
||||
|
||||
import './HeaderRightSection.styles.scss';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -29,15 +29,12 @@ interface HeaderRightSectionProps {
|
||||
enableAnnouncements: boolean;
|
||||
enableShare: boolean;
|
||||
enableFeedback: boolean;
|
||||
/** Optional page-specific toggle for the share dialog (e.g. "Include variables"). */
|
||||
shareModalExtraOption?: ShareURLExtraOption;
|
||||
}
|
||||
|
||||
function HeaderRightSection({
|
||||
enableAnnouncements,
|
||||
enableShare,
|
||||
enableFeedback,
|
||||
shareModalExtraOption,
|
||||
}: HeaderRightSectionProps): JSX.Element | null {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -188,7 +185,7 @@ function HeaderRightSection({
|
||||
rootClassName="header-section-popover-root"
|
||||
className="shareable-link-popover"
|
||||
placement="bottomRight"
|
||||
content={<ShareURLModal extraOption={shareModalExtraOption} />}
|
||||
content={<ShareURLModal />}
|
||||
open={openShareURLModal}
|
||||
destroyTooltipOnHide
|
||||
arrow={false}
|
||||
|
||||
@@ -24,22 +24,7 @@ const routesToBeSharedWithTime = [
|
||||
ROUTES.METER_EXPLORER,
|
||||
];
|
||||
|
||||
/**
|
||||
* An optional, page-specific toggle in the share dialog (e.g. a dashboard's
|
||||
* "Include variables"). When enabled, `apply` mutates the URL params that go into
|
||||
* the shared link. Keeps this shared modal generic — the page owns what it adds.
|
||||
*/
|
||||
export interface ShareURLExtraOption {
|
||||
label: string;
|
||||
defaultEnabled?: boolean;
|
||||
apply: (params: URLSearchParams) => void;
|
||||
}
|
||||
|
||||
interface ShareURLModalProps {
|
||||
extraOption?: ShareURLExtraOption;
|
||||
}
|
||||
|
||||
function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
function ShareURLModal(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
const { selectedTime } = useSelector<AppState, GlobalReducer>(
|
||||
@@ -49,9 +34,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
const [enableAbsoluteTime, setEnableAbsoluteTime] = useState(
|
||||
selectedTime !== 'custom',
|
||||
);
|
||||
const [enableExtraOption, setEnableExtraOption] = useState(
|
||||
extraOption?.defaultEnabled ?? false,
|
||||
);
|
||||
|
||||
const startTime = urlQuery.get(QueryParams.startTime);
|
||||
const endTime = urlQuery.get(QueryParams.endTime);
|
||||
@@ -111,11 +93,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
if (extraOption && enableExtraOption) {
|
||||
extraOption.apply(urlQuery);
|
||||
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
|
||||
}
|
||||
|
||||
return currentUrl;
|
||||
};
|
||||
|
||||
@@ -166,20 +143,6 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
|
||||
{extraOption && (
|
||||
<div className="absolute-relative-time-toggler-container">
|
||||
<Typography.Text className="absolute-relative-time-toggler-label">
|
||||
{extraOption.label}
|
||||
</Typography.Text>
|
||||
<div className="absolute-relative-time-toggler">
|
||||
<Switch
|
||||
value={enableExtraOption}
|
||||
onChange={(): void => setEnableExtraOption((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="share-link">
|
||||
<div className="url-share-container">
|
||||
<div className="url-share-container-header">
|
||||
|
||||
@@ -5262,24 +5262,6 @@ const onboardingConfigWithLinks = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'temporal-cloud-metrics',
|
||||
label: 'Temporal Cloud Metrics',
|
||||
imgUrl: temporalUrl,
|
||||
tags: ['metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'metrics',
|
||||
'integrations',
|
||||
'temporal',
|
||||
'temporal cloud',
|
||||
'temporal cloud metrics',
|
||||
'temporal metrics',
|
||||
'openmetrics',
|
||||
'prometheus',
|
||||
],
|
||||
link: '/docs/integrations/temporal-cloud-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'temporal',
|
||||
label: 'Temporal',
|
||||
@@ -5291,6 +5273,9 @@ const onboardingConfigWithLinks = [
|
||||
'application performance monitoring',
|
||||
'integrations',
|
||||
'temporal',
|
||||
'temporal cloud',
|
||||
'temporal logs',
|
||||
'temporal metrics',
|
||||
'temporal traces',
|
||||
'traces',
|
||||
'tracing',
|
||||
@@ -5299,6 +5284,12 @@ const onboardingConfigWithLinks = [
|
||||
desc: 'What are you using ?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'temporal-cloud',
|
||||
label: 'Cloud Metrics',
|
||||
imgUrl: temporalUrl,
|
||||
link: '/docs/integrations/temporal-cloud-metrics/',
|
||||
},
|
||||
{
|
||||
key: 'temporal-golang',
|
||||
label: 'Go',
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -39,7 +39,6 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
signalSource,
|
||||
setAttributeKeys,
|
||||
}: AgregatorFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
|
||||
|
||||
@@ -290,7 +289,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
|
||||
return (
|
||||
<AutoComplete
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
placeholder={getPlaceholder()}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { getCategorySelectOptionByName } from 'container/NewWidget/RightContainer/alertFomatCategories';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { categoryToSupport } from './config';
|
||||
import { selectStyles } from './styles';
|
||||
@@ -13,7 +13,6 @@ function BuilderUnitsFilter({
|
||||
onChange,
|
||||
yAxisUnit,
|
||||
}: IBuilderUnitsFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { currentQuery, handleOnUnitsChange } = useQueryBuilder();
|
||||
|
||||
const selectedValue = yAxisUnit || currentQuery?.unit;
|
||||
@@ -37,7 +36,7 @@ function BuilderUnitsFilter({
|
||||
Y-axis unit
|
||||
</Typography.Text>
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
style={selectStyles}
|
||||
onChange={onChangeHandler}
|
||||
value={selectedValue}
|
||||
|
||||
@@ -9,13 +9,12 @@ import {
|
||||
} from 'lib/query/transformQueryBuilderData';
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../../utils';
|
||||
import { HavingFilterProps, HavingTagRenderProps } from './types';
|
||||
|
||||
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = formula;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [localValues, setLocalValues] = useState<string[]>([]);
|
||||
@@ -172,7 +171,7 @@ function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearch/config';
|
||||
import { OrderByProps } from './types';
|
||||
@@ -13,7 +13,6 @@ function OrderByFilter({
|
||||
onChange,
|
||||
query,
|
||||
}: OrderByProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
createOptions,
|
||||
@@ -65,7 +64,7 @@ function OrderByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { isEqual, uniqWith } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -33,7 +33,6 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
disabled,
|
||||
signalSource,
|
||||
}: GroupByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [optionsData, setOptionsData] = useState<
|
||||
@@ -175,7 +174,7 @@ export const GroupByFilter = memo(function GroupByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { getHavingObject, isValidHavingValue } from '../utils';
|
||||
// ** Types
|
||||
@@ -27,7 +27,6 @@ export function HavingFilter({
|
||||
query,
|
||||
onChange,
|
||||
}: HavingFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { having } = query;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
|
||||
@@ -232,7 +231,7 @@ export function HavingFilter({
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
onSearch={handleSearch}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
@@ -85,7 +85,6 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
signalSource,
|
||||
'data-testid': dataTestId,
|
||||
}: MetricNameSelectorProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const currentMetricName =
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName ||
|
||||
query.aggregateAttribute?.key ||
|
||||
@@ -273,7 +272,7 @@ export const MetricNameSelector = memo(function MetricNameSelector({
|
||||
return (
|
||||
<AutoComplete
|
||||
className="metric-name-selector"
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
style={selectStyle}
|
||||
filterOption={false}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Select, Spin } from 'antd';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import { OrderByFilterProps } from './OrderByFilter.interfaces';
|
||||
@@ -16,7 +16,6 @@ export function OrderByFilter({
|
||||
entityVersion,
|
||||
isNewQueryV2 = false,
|
||||
}: OrderByFilterProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const {
|
||||
debouncedSearchText,
|
||||
selectedValue,
|
||||
@@ -79,7 +78,7 @@ export function OrderByFilter({
|
||||
|
||||
return (
|
||||
<Select
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
mode="tags"
|
||||
style={selectStyle}
|
||||
onSearch={handleSearchKeys}
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { FeatureKeys } from '../../../../constants/features';
|
||||
@@ -95,7 +95,6 @@ function QueryBuilderSearch({
|
||||
disableNavigationShortcuts,
|
||||
entity,
|
||||
}: QueryBuilderSearchProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { pathname } = useLocation();
|
||||
const isLogsExplorerPage = useMemo(
|
||||
() => pathname === ROUTES.LOGS_EXPLORER,
|
||||
@@ -398,7 +397,7 @@ function QueryBuilderSearch({
|
||||
<Select
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
getPopupContainer={getPopupContainer}
|
||||
getPopupContainer={popupContainer}
|
||||
transitionName=""
|
||||
choiceTransitionName=""
|
||||
virtual={false}
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
@@ -157,8 +157,6 @@ function QueryBuilderSearchV2(
|
||||
selectProps,
|
||||
} = props;
|
||||
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const { handleRunQuery, currentQuery } = useQueryBuilder();
|
||||
@@ -991,7 +989,7 @@ function QueryBuilderSearchV2(
|
||||
{...selectProps}
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
{...(hasPopupContainer ? { getPopupContainer } : {})}
|
||||
{...(hasPopupContainer ? { getPopupContainer: popupContainer } : {})}
|
||||
{...(maxTagCount ? { maxTagCount } : {})}
|
||||
key={queryTags.join('.')}
|
||||
virtual={false}
|
||||
|
||||
@@ -126,15 +126,6 @@ export default function UPlotChart({
|
||||
}
|
||||
}, [isDataEmpty, destroyPlot]);
|
||||
|
||||
/**
|
||||
* Destroy the plot on unmount. Without this, uPlot's window-level
|
||||
* `dppxchange` listener keeps the instance (and its whole detached DOM
|
||||
* subtree) alive after the component is gone.
|
||||
*/
|
||||
const destroyPlotRef = useRef(destroyPlot);
|
||||
destroyPlotRef.current = destroyPlot;
|
||||
useEffect(() => (): void => destroyPlotRef.current(), []);
|
||||
|
||||
/**
|
||||
* Handle initialization and prop changes
|
||||
*/
|
||||
|
||||
@@ -327,32 +327,6 @@ describe('UPlotChart', () => {
|
||||
expect(firstInstance.destroy).toHaveBeenCalled();
|
||||
expect(instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('destroys the instance and notifies callbacks on unmount', () => {
|
||||
const plotRef = jest.fn();
|
||||
const onDestroy = jest.fn();
|
||||
|
||||
const { unmount } = render(
|
||||
<UPlotChart
|
||||
config={createMockConfig()}
|
||||
data={validData}
|
||||
width={600}
|
||||
height={400}
|
||||
plotRef={plotRef}
|
||||
onDestroy={onDestroy}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
const firstInstance = instances[0];
|
||||
plotRef.mockClear();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(onDestroy).toHaveBeenCalledWith(firstInstance);
|
||||
expect(firstInstance.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(plotRef).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spanGaps data transformation', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { ColorPicker } from 'antd';
|
||||
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
@@ -12,11 +11,11 @@ interface ThresholdColorSelectProps {
|
||||
|
||||
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
|
||||
// as quick swatches in the picker; the full picker below covers any custom color.
|
||||
const PRESETS: { label: string; value: ThresholdColor }[] = [
|
||||
{ label: 'Red', value: ThresholdColor.RED },
|
||||
{ label: 'Orange', value: ThresholdColor.ORANGE },
|
||||
{ label: 'Green', value: ThresholdColor.GREEN },
|
||||
{ label: 'Blue', value: ThresholdColor.BLUE },
|
||||
const PRESETS: { label: string; value: string }[] = [
|
||||
{ label: 'Red', value: '#F1575F' },
|
||||
{ label: 'Orange', value: '#F5B225' },
|
||||
{ label: 'Green', value: '#2BB673' },
|
||||
{ label: 'Blue', value: '#4E74F8' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
AnyThreshold,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { ThresholdColor } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
|
||||
import type { TableColumnOption } from '../../../hooks/useTableColumns';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
@@ -23,7 +22,7 @@ import TableThresholdRow from './rows/TableThresholdRow';
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
// New thresholds default to red (the first palette preset); the user recolors per rule.
|
||||
const DEFAULT_THRESHOLD_COLOR = ThresholdColor.RED;
|
||||
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
|
||||
|
||||
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
|
||||
const ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
|
||||
@@ -73,25 +73,6 @@ describe('usePanelEditorDraft', () => {
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('flags spec-dirty when the seed differs from the saved baseline (View handoff)', () => {
|
||||
// The editor opens on a handed-off, already-edited spec (`seed`) but compares
|
||||
// against the persisted panel (`saved`) — so it starts dirty, not clean.
|
||||
const seed = panel('Memory', 'usage');
|
||||
const saved = panel('CPU', 'usage');
|
||||
|
||||
const { result } = renderHook(() => usePanelEditorDraft(seed, saved));
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is clean when the seed matches the saved baseline', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePanelEditorDraft(panel('CPU', 'usage'), panel('CPU', 'usage')),
|
||||
);
|
||||
|
||||
expect(result.current.isSpecDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('reset restores the spec and clears dirty after an edit', () => {
|
||||
const { result } = renderHook(() => usePanelEditorDraft(panel()));
|
||||
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
|
||||
|
||||
// Exercises the REAL query builder provider (not mocks) so the dirty check is
|
||||
// verified against the builder's actual re-serialization — the "always dirty"
|
||||
// regression only reproduces with the real normalization in the loop.
|
||||
|
||||
const panelType = PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
function makeSavedQueries(): DashboardtypesQueryDTO[] {
|
||||
const base: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
return toPerses(base, panelType);
|
||||
}
|
||||
|
||||
function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries,
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
describe('usePanelEditorQuerySync (real query builder)', () => {
|
||||
it('an untouched existing panel is NOT query-dirty on mount', async () => {
|
||||
const saved = makeSavedQueries();
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(saved),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
// The builder force-resets to the saved query asynchronously; once settled the
|
||||
// live query must serialize back to the saved queries → clean.
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
// And stays clean (no late re-stage flips it dirty).
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
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
|
||||
// this as always-dirty; the round-tripped baseline normalizes both sides.
|
||||
const minimalSaved: DashboardtypesQueryDTO[] = [
|
||||
{
|
||||
kind: 'time_series',
|
||||
spec: {
|
||||
plugin: {
|
||||
kind: 'signoz/CompositeQuery',
|
||||
spec: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
aggregations: [
|
||||
{ metricName: 'system_cpu_time', timeAggregation: 'avg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as unknown as DashboardtypesQueryDTO[];
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
draft: makePanel(minimalSaved),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
savedQueries: minimalSaved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
|
||||
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
|
||||
// carries the last-run (edited) query. The builder must hydrate from the URL —
|
||||
// not discard it — so the edit survives, and it must read dirty against saved.
|
||||
const saved = makeSavedQueries();
|
||||
const editedInUrl: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
id: 'edited-in-url',
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu-edited-in-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(editedInUrl)),
|
||||
);
|
||||
const setSpec = jest.fn();
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`/?${params.toString()}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
// The draft/preview open on the saved query…
|
||||
draft: makePanel(saved),
|
||||
panelType,
|
||||
setSpec,
|
||||
refetch: jest.fn(),
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
// The URL edit is retained → dirty, and it's synced into the draft so the
|
||||
// preview follows (setSpec called with the edited query).
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
|
||||
expect(setSpec).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is query-dirty when the draft carries an edit the saved panel does not (View handoff)', async () => {
|
||||
const saved = makeSavedQueries();
|
||||
const editedBase: Query = {
|
||||
...initialQueriesMap[DataSource.METRICS],
|
||||
builder: {
|
||||
...initialQueriesMap[DataSource.METRICS].builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
|
||||
legend: 'cpu-edited',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const editedQueries = toPerses(editedBase, panelType);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
usePanelEditorQuerySync({
|
||||
// The builder seeds from the draft (the handed-off edit)…
|
||||
draft: makePanel(editedQueries),
|
||||
panelType,
|
||||
setSpec: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
// …but the baseline is the persisted panel.
|
||||
savedQueries: saved,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isQueryDirty).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,6 @@ describe('usePanelEditorQuerySync', () => {
|
||||
draft?: DashboardtypesPanelDTO;
|
||||
setSpec?: jest.Mock;
|
||||
refetch?: jest.Mock;
|
||||
savedQueries?: DashboardtypesPanelSpecDTO['queries'];
|
||||
} = {},
|
||||
): {
|
||||
result: {
|
||||
@@ -120,22 +119,20 @@ describe('usePanelEditorQuerySync', () => {
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
refetch,
|
||||
savedQueries: opts.savedQueries,
|
||||
}),
|
||||
);
|
||||
return { result, setSpec, refetch, rerender };
|
||||
}
|
||||
|
||||
it('seeds the builder from the draft queries on mount (URL query, when present, wins)', () => {
|
||||
it('force-resets the builder to the saved queries on mount (discards stale URL)', () => {
|
||||
setup();
|
||||
expect(mockFromPerses).toHaveBeenCalledWith(
|
||||
SAVED_QUERIES,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
// No forceReset: useShareBuilderUrl resets to the seed only when the URL carries
|
||||
// no query, so an in-editor edit in the URL survives a refresh.
|
||||
expect(mockUseShareBuilderUrl).toHaveBeenCalledWith({
|
||||
defaultValue: SEED_V1,
|
||||
forceReset: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,127 +342,44 @@ describe('usePanelEditorQuerySync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('staged-query re-sync (browser back/forward)', () => {
|
||||
it('commits the staged query into the draft when it re-stages', () => {
|
||||
const state = builderState();
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Browser Back re-stages a different query via initQueryBuilderData; the
|
||||
// preview must follow it instead of keeping the last Run's result.
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
state.stagedQuery = {
|
||||
id: 'restaged',
|
||||
queryType: 'builder',
|
||||
} as unknown as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).toHaveBeenCalledWith({
|
||||
...makeDraft().spec,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not commit when only the live query changes (no re-stage)', () => {
|
||||
const state = builderState({
|
||||
currentQuery: { id: 'a', queryType: 'builder' } as Query,
|
||||
});
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
|
||||
const { setSpec, rerender } = setup();
|
||||
setSpec.mockClear();
|
||||
|
||||
// Live edit: currentQuery changes, staged query + structure unchanged.
|
||||
state.currentQuery = { id: 'b', queryType: 'builder' } as Query;
|
||||
rerender();
|
||||
|
||||
expect(setSpec).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query dirty + save', () => {
|
||||
// isQueryDirty compares the live query to the SAVED queries at the V5 envelope
|
||||
// level (toQueryEnvelopes is mocked identity). Drive it via an input-sensitive
|
||||
// toPerses so the envelope comparison — not getIsQueryModified — decides.
|
||||
const SAVED_BASELINE = [{ id: 'saved-baseline' }] as unknown as NonNullable<
|
||||
DashboardtypesPanelSpecDTO['queries']
|
||||
>;
|
||||
const EDITED_ENVELOPES = [
|
||||
{ id: 'edited-envelopes' },
|
||||
] as unknown as NonNullable<DashboardtypesPanelSpecDTO['queries']>;
|
||||
const editedQuery = { id: 'edited', queryType: 'builder' } as Query;
|
||||
const unchangedQuery = { id: 'unchanged', queryType: 'builder' } as Query;
|
||||
it('compares the live query against the builder baseline (first staged query), not the raw seed', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
|
||||
beforeEach(() => {
|
||||
mockToPerses.mockImplementation((query: Query) =>
|
||||
query?.id === 'edited' ? EDITED_ENVELOPES : SAVED_BASELINE,
|
||||
// Baseline is the builder's own normalized staged query — immune to the
|
||||
// raw-seed vs builder-normalized serialization drift.
|
||||
expect(mockGetIsQueryModified).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
STAGED_V1,
|
||||
);
|
||||
});
|
||||
|
||||
it('is query-dirty when the live query no longer serializes to the saved queries', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('is not query-dirty when the live query still serializes to the saved queries', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
it('is not query-dirty when the live query matches the baseline', () => {
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('buildSaveSpec bakes the live query in when dirty', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
mockGetIsQueryModified.mockReturnValue(true);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toStrictEqual({
|
||||
...spec,
|
||||
queries: EDITED_ENVELOPES,
|
||||
queries: CONVERTED_QUERIES,
|
||||
});
|
||||
});
|
||||
|
||||
it('buildSaveSpec returns the spec untouched when the query is unchanged', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup({ savedQueries: SAVED_BASELINE });
|
||||
mockGetIsQueryModified.mockReturnValue(false);
|
||||
const { result } = setup();
|
||||
const { spec } = makeDraft();
|
||||
|
||||
expect(result.current.buildSaveSpec(spec)).toBe(spec);
|
||||
});
|
||||
|
||||
it('anchors the baseline to savedQueries, not the draft the builder seeds from (View handoff / refresh)', () => {
|
||||
// The draft carries the View-mode edit (the builder seeds from it), but the
|
||||
// baseline is the persisted panel: a live query equal to the edited draft
|
||||
// still reads dirty against the saved queries.
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: editedQuery }),
|
||||
);
|
||||
const draft = makeDraft(EDITED_ENVELOPES);
|
||||
const { result } = setup({ draft, savedQueries: SAVED_BASELINE });
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the seed query as the baseline when there are no saved queries (new panel)', () => {
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ currentQuery: unchangedQuery }),
|
||||
);
|
||||
const { result } = setup();
|
||||
|
||||
expect(result.current.isQueryDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,6 @@ import { usePanelTypeSwitch } from './usePanelTypeSwitch';
|
||||
interface UsePanelEditSessionArgs {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new
|
||||
* panel or the drilldown modal, where the seed is the baseline.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Per-view time window (epoch ms); omit to follow the dashboard's global window. */
|
||||
time?: PanelQueryTimeOverride;
|
||||
/** Serialize the live builder query into the spec on save even if unchanged (new panels). */
|
||||
@@ -73,15 +67,12 @@ export interface UsePanelEditSessionReturn {
|
||||
export function usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
|
||||
panel,
|
||||
savedPanel,
|
||||
);
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } =
|
||||
usePanelEditorDraft(panel);
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
@@ -102,7 +93,6 @@ export function usePanelEditSession({
|
||||
refetch: query.refetch,
|
||||
alwaysSerializeQuery,
|
||||
signal: seedQuerySignal ? defaultSignal : undefined,
|
||||
savedQueries: savedPanel?.spec.queries,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
|
||||
@@ -13,14 +13,9 @@ import type { PanelEditorDraftApi } from '../types';
|
||||
* preview renders it through the dashboard's renderer registry and the save hook
|
||||
* patches it without conversion. Everything the config pane edits flows through the
|
||||
* single `spec`/`setSpec` pair.
|
||||
*
|
||||
* `savedPanel` is the persisted panel the dirty check compares against — distinct from
|
||||
* `initialPanel` (the seed), which may carry unsaved edits handed off from View mode.
|
||||
* Defaults to the seed when there's no separate saved baseline (a new panel).
|
||||
*/
|
||||
export function usePanelEditorDraft(
|
||||
initialPanel: DashboardtypesPanelDTO,
|
||||
savedPanel: DashboardtypesPanelDTO = initialPanel,
|
||||
): PanelEditorDraftApi {
|
||||
const [draft, setDraft] = useState<DashboardtypesPanelDTO>(initialPanel);
|
||||
|
||||
@@ -40,9 +35,9 @@ export function usePanelEditorDraft(
|
||||
() =>
|
||||
!isEqual(
|
||||
{ ...draft, spec: { ...draft.spec, queries: null } },
|
||||
{ ...savedPanel, spec: { ...savedPanel.spec, queries: null } },
|
||||
{ ...initialPanel, spec: { ...initialPanel.spec, queries: null } },
|
||||
),
|
||||
[draft, savedPanel],
|
||||
[draft, initialPanel],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesQueryDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -28,12 +27,6 @@ interface UsePanelEditorQuerySyncArgs {
|
||||
alwaysSerializeQuery?: boolean;
|
||||
/** Signal to seed a new panel's builder with — the kind's first supported signal. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/**
|
||||
* The persisted panel's queries — the dirty baseline. Distinct from `draft.spec.queries`,
|
||||
* which the builder seeds from and may carry unsaved edits handed off from View mode. Omit
|
||||
* for a new panel, where the seed query is the baseline.
|
||||
*/
|
||||
savedQueries?: DashboardtypesQueryDTO[];
|
||||
}
|
||||
|
||||
interface UsePanelEditorQuerySyncApi {
|
||||
@@ -60,31 +53,43 @@ export function usePanelEditorQuerySync({
|
||||
refetch,
|
||||
alwaysSerializeQuery = false,
|
||||
signal,
|
||||
savedQueries,
|
||||
}: UsePanelEditorQuerySyncArgs): UsePanelEditorQuerySyncApi {
|
||||
const { currentQuery, stagedQuery, handleRunQuery } = useQueryBuilder();
|
||||
|
||||
const draftQueries = draft.spec.queries;
|
||||
// Saved queries, captured once: seed the builder and serve as the restore target.
|
||||
const savedQueries = draft.spec.queries;
|
||||
|
||||
// A new panel has no saved query: seed from the kind's first supported signal rather
|
||||
// than `fromPerses`'s metrics default (which List doesn't support).
|
||||
// A new panel has no saved query: seed from the kind's first supported signal
|
||||
// instead of letting `fromPerses` fall back to the metrics default (which List
|
||||
// doesn't support).
|
||||
const seedQuery = useMemo(
|
||||
() =>
|
||||
draftQueries.length === 0 && signal
|
||||
savedQueries.length === 0 && signal
|
||||
? initialQueriesMap[signal]
|
||||
: fromPerses(draftQueries, panelType),
|
||||
[draftQueries, panelType, signal],
|
||||
: fromPerses(savedQueries, panelType),
|
||||
[savedQueries, panelType, signal],
|
||||
);
|
||||
// No forceReset: seed the builder only when the URL carries no query, so an
|
||||
// in-editor edit in the URL survives a refresh / browser Back-Forward.
|
||||
useShareBuilderUrl({ defaultValue: seedQuery });
|
||||
// Force-reset the builder to the SAVED panel on first render only, discarding a
|
||||
// stale URL query from a prior edit (else the QB/preview diverge and the dirty
|
||||
// baseline is captured from the URL). After mount the URL syncs normally.
|
||||
const isInitialRenderRef = useRef(true);
|
||||
useShareBuilderUrl({
|
||||
defaultValue: seedQuery,
|
||||
forceReset: isInitialRenderRef.current,
|
||||
});
|
||||
useEffect(() => {
|
||||
isInitialRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Commit the live query into the draft (what the preview fetches).
|
||||
// Commit the live query into the draft (what the preview fetches). The dirty
|
||||
// check compares against the SAVED query (`seedQuery`), not the URL-synced
|
||||
// staged query, which can carry stale state across a refresh and read a real
|
||||
// switch as "unchanged". Returns whether the draft changed.
|
||||
const commitQuery = useCallback(
|
||||
(query: Query): boolean => {
|
||||
const next = getIsQueryModified(query, seedQuery)
|
||||
? toPerses(query, panelType)
|
||||
: draftQueries;
|
||||
: savedQueries;
|
||||
// No-op guard at the V5 envelope level: equivalent wrappers (bare
|
||||
// `signoz/BuilderQuery` vs `signoz/CompositeQuery`) unwrap to the same
|
||||
// envelopes, so a structural compare would falsely dirty the draft.
|
||||
@@ -95,7 +100,7 @@ export function usePanelEditorQuerySync({
|
||||
setSpec({ ...draft.spec, queries: next });
|
||||
return true;
|
||||
},
|
||||
[seedQuery, panelType, draftQueries, draft.spec, setSpec],
|
||||
[seedQuery, panelType, savedQueries, draft.spec, setSpec],
|
||||
);
|
||||
|
||||
// Latest query/commit, read by the structural-change effect without re-subscribing.
|
||||
@@ -105,7 +110,7 @@ export function usePanelEditorQuerySync({
|
||||
queryRef.current = currentQuery;
|
||||
|
||||
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
|
||||
// mount: the initial query is synced into the draft by the staged-query effect below.
|
||||
// mount: the draft already holds the saved queries the builder is reset to.
|
||||
const dataSources = useMemo(
|
||||
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
|
||||
[currentQuery.builder],
|
||||
@@ -131,15 +136,6 @@ export function usePanelEditorQuerySync({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- structural change only
|
||||
}, [currentQuery.queryType, dataSourceSignature]);
|
||||
|
||||
// Follow the staged (executed) query into the draft on a URL re-stage (mount
|
||||
// hydration, browser Back/Forward) so the preview matches. Live edits touch only
|
||||
// currentQuery, so they still wait for Run; commitQuery no-ops when unchanged.
|
||||
useEffect(() => {
|
||||
if (stagedQuery) {
|
||||
commitRef.current(stagedQuery);
|
||||
}
|
||||
}, [stagedQuery]);
|
||||
|
||||
// Stage & Run / ⌘↵: stage, commit, and re-fetch when unchanged so it can be re-run.
|
||||
const runQuery = useCallback((): void => {
|
||||
handleRunQuery();
|
||||
@@ -148,29 +144,20 @@ export function usePanelEditorQuerySync({
|
||||
}
|
||||
}, [handleRunQuery, commitQuery, currentQuery, refetch]);
|
||||
|
||||
// Dirty = the live query no longer serializes to the SAVED panel's query, compared at
|
||||
// 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.
|
||||
const baselineEnvelopes = useMemo(
|
||||
() =>
|
||||
toQueryEnvelopes(
|
||||
toPerses(
|
||||
savedQueries ? fromPerses(savedQueries, panelType) : seedQuery,
|
||||
panelType,
|
||||
),
|
||||
),
|
||||
[savedQueries, seedQuery, panelType],
|
||||
);
|
||||
const isQueryDirty = useMemo(
|
||||
() =>
|
||||
!isEqual(
|
||||
toQueryEnvelopes(toPerses(currentQuery, panelType)),
|
||||
baselineEnvelopes,
|
||||
),
|
||||
[currentQuery, panelType, baselineEnvelopes],
|
||||
);
|
||||
// Dirty baseline: the builder's OWN normalized saved query (first non-null
|
||||
// `stagedQuery` after the mount reset) — comparing builder-normalized to
|
||||
// builder-normalized avoids serialization drift reading an untouched query as
|
||||
// modified. In state (not a ref) so capture re-triggers `isQueryDirty`; captured
|
||||
// once and never moved by Stage & Run, so it stays anchored to saved.
|
||||
const [queryBaseline, setQueryBaseline] = useState<Query | null>(null);
|
||||
useEffect(() => {
|
||||
if (queryBaseline === null && stagedQuery) {
|
||||
setQueryBaseline(stagedQuery);
|
||||
}
|
||||
}, [queryBaseline, stagedQuery]);
|
||||
|
||||
const isQueryDirty =
|
||||
queryBaseline !== null && getIsQueryModified(currentQuery, queryBaseline);
|
||||
|
||||
const buildSaveSpec = useCallback(
|
||||
(spec: DashboardtypesPanelSpecDTO): DashboardtypesPanelSpecDTO =>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
@@ -42,22 +41,10 @@ import styles from './PanelEditor.module.scss';
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
|
||||
// popups (group-by, order-by, having, …) clip when they open into the short pane.
|
||||
// Portal them to the document body; the query-builder filters honor this via
|
||||
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
|
||||
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
|
||||
const getBodyPopupContainer = (): HTMLElement => document.body;
|
||||
|
||||
interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
@@ -81,7 +68,6 @@ function PanelEditorContainer({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
isEditable,
|
||||
@@ -105,7 +91,6 @@ function PanelEditorContainer({
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
});
|
||||
@@ -303,24 +288,22 @@ function PanelEditorContainer({
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
|
||||
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={listSignal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
footer={
|
||||
isListPanel ? (
|
||||
<ListColumnsEditor
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
signal={listSignal}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
@@ -22,22 +22,6 @@ export interface ComparisonThresholdShape {
|
||||
format?: DashboardtypesThresholdFormatDTO;
|
||||
}
|
||||
|
||||
/** SigNoz threshold palette; single source of truth for the hex values. */
|
||||
export enum ThresholdColor {
|
||||
RED = '#F1575F',
|
||||
ORANGE = '#F5B225',
|
||||
GREEN = '#2BB673',
|
||||
BLUE = '#4E74F8',
|
||||
}
|
||||
|
||||
/** Palette ordered most-dangerous first (preset order + alert-severity ranking). */
|
||||
export const THRESHOLD_COLOR_DANGER_ORDER: ThresholdColor[] = [
|
||||
ThresholdColor.RED,
|
||||
ThresholdColor.ORANGE,
|
||||
ThresholdColor.GREEN,
|
||||
ThresholdColor.BLUE,
|
||||
];
|
||||
|
||||
/** Comparison operators a threshold can use, as evaluable symbols. */
|
||||
export type ThresholdComparisonOperator = '>' | '<' | '>=' | '<=' | '=' | '!=';
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type {
|
||||
Querybuildertypesv5QueryWarnDataDTO as WarningDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { Querybuildertypesv5QueryWarnDataDTO as WarningDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
|
||||
import { panelStatusFromError, panelStatusFromWarning } from '../utils';
|
||||
@@ -46,7 +44,7 @@ describe('panelStatusFromError', () => {
|
||||
|
||||
it('falls back to the error message when there is no structured body', () => {
|
||||
expect(panelStatusFromError(new Error('boom'))).toStrictEqual({
|
||||
code: 'UPSTREAM_UNAVAILABLE',
|
||||
code: 'unknown_error',
|
||||
message: 'boom',
|
||||
docsUrl: undefined,
|
||||
messages: [],
|
||||
|
||||
@@ -104,9 +104,7 @@ function ViewPanelModalContent({
|
||||
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
openPanelEditor(panelId, {
|
||||
handoffState: { editSpec: buildSaveSpec(draft.spec) },
|
||||
});
|
||||
openPanelEditor(panelId, { editSpec: buildSaveSpec(draft.spec) });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,13 +5,11 @@ import type {
|
||||
DashboardtypesPanelDTO,
|
||||
DashboardtypesPanelPluginDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { normalizeOperator } from 'container/CreateAlertV2/context/conditionNormalizers';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
AlertThresholdOperator,
|
||||
Threshold,
|
||||
} from 'container/CreateAlertV2/context/types';
|
||||
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/threshold';
|
||||
import type { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -23,6 +21,14 @@ export interface PanelAlertPrefill {
|
||||
threshold?: Threshold;
|
||||
}
|
||||
|
||||
// Most-dangerous first, matching the panel editor palette; unknown colors sort last.
|
||||
const THRESHOLD_COLOR_DANGER_ORDER = [
|
||||
'#f1575f',
|
||||
'#f5b225',
|
||||
'#2bb673',
|
||||
'#4e74f8',
|
||||
];
|
||||
|
||||
interface NormalizedPanelThreshold {
|
||||
color: string;
|
||||
value: number;
|
||||
@@ -87,12 +93,8 @@ function readPanelThresholds(
|
||||
}
|
||||
}
|
||||
|
||||
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.
|
||||
function colorRank(color: string): number {
|
||||
const target = color.toLowerCase();
|
||||
const index = THRESHOLD_COLOR_DANGER_ORDER.findIndex(
|
||||
(paletteColor) => paletteColor.toLowerCase() === target,
|
||||
);
|
||||
const index = THRESHOLD_COLOR_DANGER_ORDER.indexOf(color.toLowerCase());
|
||||
return index === -1 ? THRESHOLD_COLOR_DANGER_ORDER.length : index;
|
||||
}
|
||||
|
||||
@@ -104,17 +106,22 @@ function pickHighestDanger(
|
||||
)[0];
|
||||
}
|
||||
|
||||
// The alert UI has no inclusive operator; collapse "or equal" onto its strict variant.
|
||||
function panelOperatorToAlertOperator(
|
||||
operator: DashboardtypesComparisonOperatorDTO | undefined,
|
||||
): AlertThresholdOperator | undefined {
|
||||
switch (operator) {
|
||||
case 'above':
|
||||
case 'above_or_equal':
|
||||
return normalizeOperator('above');
|
||||
return AlertThresholdOperator.IS_ABOVE;
|
||||
case 'below':
|
||||
case 'below_or_equal':
|
||||
return normalizeOperator('below');
|
||||
return AlertThresholdOperator.IS_BELOW;
|
||||
case 'equal':
|
||||
return AlertThresholdOperator.IS_EQUAL_TO;
|
||||
case 'not_equal':
|
||||
return AlertThresholdOperator.IS_NOT_EQUAL_TO;
|
||||
default:
|
||||
return normalizeOperator(operator);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
import { useShareVariablesOption } from './useShareVariablesOption';
|
||||
|
||||
import styles from './DashboardPageHeader.module.scss';
|
||||
|
||||
@@ -15,16 +14,10 @@ function DashboardPageHeader({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
const shareVariablesOption = useShareVariablesOption();
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
shareModalExtraOption={shareVariablesOption}
|
||||
/>
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ShareURLExtraOption } from 'components/HeaderRightSection/ShareURLModal';
|
||||
|
||||
import type { SelectedVariableValue } from '../../VariablesBar/selectionTypes';
|
||||
import {
|
||||
ALL_SELECTED,
|
||||
variablesUrlParser,
|
||||
} from '../../VariablesBar/utils/variablesUrlState';
|
||||
import { selectVariableValues } from '../../store/slices/variableSelectionSlice';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
/**
|
||||
* The share-dialog "Include variables" option: serializes the current variable
|
||||
* selection into the `?variables=` param (ALL encoded as the sentinel) so a shared
|
||||
* link reproduces it for the recipient — who hydrates it into local storage on load,
|
||||
* after which the param is cleared (see useSeedVariableSelection). Returns undefined
|
||||
* when there is nothing selected to share.
|
||||
*/
|
||||
export function useShareVariablesOption(): ShareURLExtraOption | undefined {
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const selections = useDashboardStore(selectVariableValues(dashboardId ?? ''));
|
||||
|
||||
return useMemo(() => {
|
||||
const names = Object.keys(selections);
|
||||
if (names.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const urlShape: Record<string, SelectedVariableValue> = {};
|
||||
names.forEach((name) => {
|
||||
const selection = selections[name];
|
||||
urlShape[name] = selection.allSelected ? ALL_SELECTED : selection.value;
|
||||
});
|
||||
const serialized = variablesUrlParser.serialize(urlShape);
|
||||
return {
|
||||
label: 'Include variables',
|
||||
apply: (params): void => {
|
||||
params.set('variables', serialized);
|
||||
},
|
||||
};
|
||||
}, [selections]);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useCreatePanel } from '../useCreatePanel';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}));
|
||||
|
||||
describe('useCreatePanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
|
||||
});
|
||||
|
||||
it('carries the relative time window onto the new-panel route', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('/dashboard/dash-1/panel/new');
|
||||
expect(url).toContain('panelKind=timeSeries');
|
||||
expect(url).toContain('layoutIndex=2');
|
||||
expect(url).toContain('relativeTime=6h');
|
||||
});
|
||||
|
||||
it('carries a custom absolute window and never a stray relativeTime', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useCreatePanel());
|
||||
act(() => {
|
||||
result.current.createPanel('timeSeries' as never, 2);
|
||||
});
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('startTime=1000');
|
||||
expect(url).toContain('endTime=2000');
|
||||
expect(url).not.toContain('relativeTime');
|
||||
expect(url).not.toContain('&&');
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useOpenPanelEditor } from '../useOpenPanelEditor';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
jest.mock('../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ dashboardId: 'dash-1' }),
|
||||
}));
|
||||
|
||||
describe('useOpenPanelEditor', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalTime = { selectedTime: '30m', minTime: 0, maxTime: 0 };
|
||||
});
|
||||
|
||||
it('carries the relative time window into the editor route', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9?relativeTime=6h',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries a custom absolute window as a start/end ms pair', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('startTime=1000');
|
||||
expect(url).toContain('endTime=2000');
|
||||
// A custom range must not also carry relativeTime (it would win on the editor).
|
||||
expect(url).not.toContain('relativeTime');
|
||||
});
|
||||
|
||||
it('omits the query string for an uninitialized custom window', () => {
|
||||
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('panel-9');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards handoff state as router location state', () => {
|
||||
mockGlobalTime = { selectedTime: '1h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
const handoffState = { editSpec: { title: 'x' } } as never;
|
||||
result.current('panel-9', { handoffState });
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard/dash-1/panel/panel-9?relativeTime=1h',
|
||||
{ state: handoffState },
|
||||
);
|
||||
});
|
||||
|
||||
it('merges search with the time window (leading ? tolerated)', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useOpenPanelEditor());
|
||||
result.current('new', { search: '?panelKind=timeSeries&layoutIndex=2' });
|
||||
|
||||
const [url] = mockSafeNavigate.mock.calls[0];
|
||||
expect(url).toContain('/dashboard/dash-1/panel/new?');
|
||||
expect(url).toContain('panelKind=timeSeries');
|
||||
expect(url).toContain('layoutIndex=2');
|
||||
expect(url).toContain('relativeTime=6h');
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { useTimeSearchParams } from '../useTimeSearchParams';
|
||||
|
||||
let mockGlobalTime = {
|
||||
selectedTime: '30m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
};
|
||||
jest.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown =>
|
||||
selector({ globalTime: mockGlobalTime }),
|
||||
}));
|
||||
|
||||
describe('useTimeSearchParams', () => {
|
||||
it('returns a relativeTime query string for a relative selection', () => {
|
||||
mockGlobalTime = { selectedTime: '6h', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toBe('relativeTime=6h');
|
||||
});
|
||||
|
||||
it('returns an absolute ms pair for a custom selection', () => {
|
||||
mockGlobalTime = {
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
};
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toContain('startTime=1000');
|
||||
expect(result.current).toContain('endTime=2000');
|
||||
expect(result.current).not.toContain('relativeTime');
|
||||
});
|
||||
|
||||
it('returns an empty string for an uninitialized custom window', () => {
|
||||
mockGlobalTime = { selectedTime: 'custom', minTime: 0, maxTime: 0 };
|
||||
const { result } = renderHook(() => useTimeSearchParams());
|
||||
|
||||
expect(result.current).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import { newPanelSearch, NEW_PANEL_ID } from '../PanelEditor/newPanelRoute';
|
||||
import type { PanelKind } from '../Panels/types/panelKind';
|
||||
import { useOpenPanelEditor } from './useOpenPanelEditor';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
|
||||
interface UseCreatePanelResult {
|
||||
isPickerOpen: boolean;
|
||||
@@ -21,7 +24,8 @@ interface UseCreatePanelResult {
|
||||
* until save.
|
||||
*/
|
||||
export function useCreatePanel(): UseCreatePanelResult {
|
||||
const openPanelEditor = useOpenPanelEditor();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||
// Captured on open, consumed on select.
|
||||
@@ -39,12 +43,15 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
openPanelEditor(NEW_PANEL_ID, {
|
||||
search: newPanelSearch(panelKind, target),
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
// Variable selection is read from the persisted store, not the URL.
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
},
|
||||
[openPanelEditor, layoutIndex],
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,39 +5,30 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import type { PanelEditorHandoffState } from '../PanelEditor/panelEditorHandoff';
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import { useTimeSearchParams } from './useTimeSearchParams';
|
||||
|
||||
interface OpenPanelEditorOptions {
|
||||
handoffState?: PanelEditorHandoffState;
|
||||
/** Extra query merged into the editor URL (leading `?` optional). */
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/** Opens the V2 panel editor, carrying the active time window in the URL. */
|
||||
/**
|
||||
* Returns a callback that opens the V2 panel editor by navigating to its full-page route
|
||||
* (`/dashboard/:dashboardId/panel/:panelId`). The dashboard id comes from the store, so any
|
||||
* caller can open the editor with just the panel id. Variable selection is read from the
|
||||
* persisted store (localStorage), not carried in the URL. The optional `handoffState` is
|
||||
* passed as router location state — the View modal uses it to hand its drilldown-edited spec
|
||||
* off to the editor (view → edit) so the editor opens on those edits rather than the saved
|
||||
* panel.
|
||||
*/
|
||||
export function useOpenPanelEditor(): (
|
||||
panelId: string,
|
||||
options?: OpenPanelEditorOptions,
|
||||
handoffState?: PanelEditorHandoffState,
|
||||
) => void {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
return useCallback(
|
||||
(panelId: string, options?: OpenPanelEditorOptions): void => {
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId,
|
||||
});
|
||||
const params = new URLSearchParams(options?.search);
|
||||
new URLSearchParams(timeSearch).forEach((value, key) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
const search = params.toString();
|
||||
(panelId: string, handoffState?: PanelEditorHandoffState): void => {
|
||||
safeNavigate(
|
||||
search ? `${path}?${search}` : path,
|
||||
options?.handoffState ? { state: options.handoffState } : undefined,
|
||||
generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, { dashboardId, panelId }),
|
||||
handoffState ? { state: handoffState } : undefined,
|
||||
);
|
||||
},
|
||||
[safeNavigate, dashboardId, timeSearch],
|
||||
[safeNavigate, dashboardId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../utils/timeUrlParams';
|
||||
|
||||
/** Active time window as a query string (no leading `?`), or `''` when unset. */
|
||||
export function useTimeSearchParams(): string {
|
||||
const { selectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
|
||||
return useMemo(
|
||||
() => timeParamsFromGlobalTime({ selectedTime, minTime, maxTime }).toString(),
|
||||
[selectedTime, minTime, maxTime],
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
|
||||
import { timeParamsFromGlobalTime } from '../timeUrlParams';
|
||||
|
||||
describe('timeParamsFromGlobalTime', () => {
|
||||
it('emits relativeTime for a relative selection', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: '6h',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.get('relativeTime')).toBe('6h');
|
||||
// Mutually exclusive: no absolute pair alongside a relative range.
|
||||
expect(params.has('startTime')).toBe(false);
|
||||
expect(params.has('endTime')).toBe(false);
|
||||
});
|
||||
|
||||
it('emits an absolute ms pair for a custom selection (converting from ns)', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: 'custom',
|
||||
minTime: 1000 * NANO_SECOND_MULTIPLIER,
|
||||
maxTime: 2000 * NANO_SECOND_MULTIPLIER,
|
||||
});
|
||||
|
||||
expect(params.get('startTime')).toBe('1000');
|
||||
expect(params.get('endTime')).toBe('2000');
|
||||
// A custom range must not carry a relativeTime that would win on the editor.
|
||||
expect(params.has('relativeTime')).toBe(false);
|
||||
});
|
||||
|
||||
it('carries a custom shorthand relative selection verbatim', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: '13m',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.get('relativeTime')).toBe('13m');
|
||||
});
|
||||
|
||||
it('emits nothing for an uninitialized custom window', () => {
|
||||
const params = timeParamsFromGlobalTime({
|
||||
selectedTime: 'custom',
|
||||
minTime: 0,
|
||||
maxTime: 0,
|
||||
});
|
||||
|
||||
expect(params.toString()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
import type { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
type GlobalTimeSelection = Pick<
|
||||
GlobalReducer,
|
||||
'selectedTime' | 'minTime' | 'maxTime'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Time-window URL params for the active selection. Derived from Redux (what the picker and
|
||||
* panel queries read), not the URL: the legacy react-router and newer nuqs time writers fall
|
||||
* out of sync, leaving a stale `relativeTime` that `DateTimeSelectionV2` prefers over an
|
||||
* absolute range. Redux keeps them mutually exclusive (custom → start/end ms; else relativeTime).
|
||||
*/
|
||||
export function timeParamsFromGlobalTime({
|
||||
selectedTime,
|
||||
minTime,
|
||||
maxTime,
|
||||
}: GlobalTimeSelection): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedTime === 'custom') {
|
||||
if (minTime > 0 && maxTime > 0) {
|
||||
params.set(
|
||||
QueryParams.startTime,
|
||||
String(Math.floor(minTime / NANO_SECOND_MULTIPLIER)),
|
||||
);
|
||||
params.set(
|
||||
QueryParams.endTime,
|
||||
String(Math.floor(maxTime / NANO_SECOND_MULTIPLIER)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
params.set(QueryParams.relativeTime, selectedTime);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
parseNewPanelLayoutIndex,
|
||||
} from '../DashboardContainer/PanelEditor/newPanelRoute';
|
||||
import { useSyncVariablesForSuggestions } from '../DashboardContainer/hooks/useSyncVariablesForSuggestions';
|
||||
import { useTimeSearchParams } from '../DashboardContainer/hooks/useTimeSearchParams';
|
||||
import { createDefaultPanel } from '../DashboardContainer/patchOps';
|
||||
import { useDashboardStore } from '../DashboardContainer/store/useDashboardStore';
|
||||
import { useSeedVariableSelection } from '../DashboardContainer/VariablesBar/hooks/useSeedVariableSelection';
|
||||
@@ -39,7 +38,6 @@ function PanelEditorPage(): JSX.Element {
|
||||
}>();
|
||||
const { search, state } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const timeSearch = useTimeSearchParams();
|
||||
|
||||
// Edits handed off from the View modal's drilldown — open the editor on these
|
||||
// instead of the saved panel. Lost on refresh/new-tab, which falls back to saved.
|
||||
@@ -107,11 +105,10 @@ function PanelEditorPage(): JSX.Element {
|
||||
const layoutIndex = parseNewPanelLayoutIndex(search);
|
||||
|
||||
const backToDashboard = useCallback((): void => {
|
||||
// Drop editor-only URL state (variables come from the persisted store), but carry
|
||||
// time so a custom range picked in the editor isn't reset to the dashboard default.
|
||||
const path = generatePath(ROUTES.DASHBOARD, { dashboardId });
|
||||
safeNavigate(timeSearch ? `${path}?${timeSearch}` : path);
|
||||
}, [safeNavigate, dashboardId, timeSearch]);
|
||||
// Drop editor-only URL state (chiefly `compositeQuery`); the dashboard reads its
|
||||
// variable selection from the persisted store, and time lives in Redux.
|
||||
safeNavigate(generatePath(ROUTES.DASHBOARD, { dashboardId }));
|
||||
}, [safeNavigate, dashboardId]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
@@ -140,7 +137,6 @@ function PanelEditorPage(): JSX.Element {
|
||||
dashboardId={dashboardId}
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
savedPanel={existingPanel}
|
||||
isNew={!!newKind}
|
||||
layoutIndex={layoutIndex}
|
||||
isEditable={isEditable}
|
||||
|
||||
@@ -42,12 +42,7 @@ export function toAPIError(
|
||||
try {
|
||||
ErrorResponseHandlerForGeneratedAPIs(error);
|
||||
} catch (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'
|
||||
) {
|
||||
if (apiError instanceof APIError) {
|
||||
return apiError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import { ConfigProvider, SelectProps } from 'antd';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useContext } from 'react';
|
||||
import { SelectProps } from 'antd';
|
||||
|
||||
export const popupContainer: SelectProps['getPopupContainer'] = (
|
||||
trigger,
|
||||
): HTMLElement => trigger.parentNode;
|
||||
|
||||
/**
|
||||
* Popup container for query-builder Selects. Prefers a container supplied by an
|
||||
* ancestor antd `ConfigProvider` (set by hosts that render the builder inside a
|
||||
* clipped/portaled surface — e.g. the panel editor's `overflow:hidden` resizable
|
||||
* pane, or the View modal's focus-trapped dialog) and otherwise falls back to
|
||||
* `trigger.parentNode`, the app-wide default. No `ConfigProvider` container is set
|
||||
* app-wide, so surfaces that don't opt in keep the legacy behavior unchanged.
|
||||
*/
|
||||
export function useSelectPopupContainer(): SelectProps['getPopupContainer'] {
|
||||
const { getPopupContainer } = useContext(ConfigProvider.ConfigContext);
|
||||
return getPopupContainer ?? popupContainer;
|
||||
}
|
||||
|
||||
@@ -12,15 +12,13 @@ import (
|
||||
|
||||
var (
|
||||
ErrCodeInvalidGlobalConfig = errors.MustNewCode("invalid_global_config")
|
||||
ErrCodeOriginNotAllowed = errors.MustNewCode("origin_not_allowed")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ExternalURL *url.URL `mapstructure:"external_url"`
|
||||
AllowedOrigins []*url.URL `mapstructure:"allowed_origins"`
|
||||
IngestionURL *url.URL `mapstructure:"ingestion_url"`
|
||||
MCPURL *url.URL `mapstructure:"mcp_url"`
|
||||
AIAssistantURL *url.URL `mapstructure:"ai_assistant_url"`
|
||||
ExternalURL *url.URL `mapstructure:"external_url"`
|
||||
IngestionURL *url.URL `mapstructure:"ingestion_url"`
|
||||
MCPURL *url.URL `mapstructure:"mcp_url"`
|
||||
AIAssistantURL *url.URL `mapstructure:"ai_assistant_url"`
|
||||
}
|
||||
|
||||
func NewConfigFactory() factory.ConfigFactory {
|
||||
@@ -51,33 +49,9 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if origin == nil || origin.Scheme == "" || origin.Host == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::allowed_origins entries must be of the form scheme://host[:port], got %q", origin)
|
||||
}
|
||||
|
||||
if origin.Path != "" && origin.Path != "/" {
|
||||
return errors.NewInvalidInputf(ErrCodeInvalidGlobalConfig, "global::allowed_origins entries must not contain a path, got %q", origin)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) IsOriginAllowed(u *url.URL) bool {
|
||||
if len(c.AllowedOrigins) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if strings.EqualFold(origin.Scheme, u.Scheme) && strings.EqualFold(origin.Host, u.Host) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Config) ExternalPath() string {
|
||||
if c.ExternalURL == nil || c.ExternalURL.Path == "" || c.ExternalURL.Path == "/" {
|
||||
return ""
|
||||
|
||||
@@ -123,26 +123,6 @@ func TestValidate(t *testing.T) {
|
||||
config: Config{ExternalURL: &url.URL{Path: "signoz"}},
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
name: "ValidAllowedOrigin",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https", Host: "signoz.example.com"}}},
|
||||
fail: false,
|
||||
},
|
||||
{
|
||||
name: "AllowedOriginWithoutScheme",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Host: "signoz.example.com"}}},
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
name: "AllowedOriginWithoutHost",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https"}}},
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
name: "AllowedOriginWithPath",
|
||||
config: Config{AllowedOrigins: []*url.URL{{Scheme: "https", Host: "signoz.example.com", Path: "/login"}}},
|
||||
fail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -157,96 +137,3 @@ func TestValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOriginAllowedWhenUnconfigured(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
config Config
|
||||
}{
|
||||
{
|
||||
name: "Empty",
|
||||
config: Config{},
|
||||
},
|
||||
{
|
||||
name: "ExternalURLDoesNotActivateValidation",
|
||||
config: Config{ExternalURL: &url.URL{Scheme: "https", Host: "signoz.example.com"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u, err := url.Parse("https://anything.example.com/login")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, tc.config.IsOriginAllowed(u))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOriginAllowed(t *testing.T) {
|
||||
config := Config{
|
||||
AllowedOrigins: []*url.URL{
|
||||
{Scheme: "https", Host: "signoz.example.com"},
|
||||
{Scheme: "http", Host: "localhost:3301"},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "ConfiguredOrigin",
|
||||
input: "https://signoz.example.com/login",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ConfiguredOriginWithQuery",
|
||||
input: "http://localhost:3301/login?next=/dashboards",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "CaseInsensitiveHost",
|
||||
input: "https://SigNoz.Example.Com/login",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "UnknownHost",
|
||||
input: "https://attacker.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SchemeMismatch",
|
||||
input: "http://signoz.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "PortMismatch",
|
||||
input: "https://signoz.example.com:8443/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SuffixConfusion",
|
||||
input: "https://evilsignoz.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "UserInfoConfusion",
|
||||
input: "https://signoz.example.com@attacker.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SchemeRelative",
|
||||
input: "//attacker.example.com/login",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
u, err := url.Parse(tc.input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, config.IsOriginAllowed(u))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/authz"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/modules/authdomain"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
@@ -24,28 +23,26 @@ import (
|
||||
)
|
||||
|
||||
type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
authNs map[authtypes.AuthNProvider]authn.AuthN
|
||||
userSetter user.Setter
|
||||
userGetter user.Getter
|
||||
authDomain authdomain.Module
|
||||
tokenizer tokenizer.Tokenizer
|
||||
orgGetter organization.Getter
|
||||
authz authz.AuthZ
|
||||
globalConfig global.Config
|
||||
settings factory.ScopedProviderSettings
|
||||
authNs map[authtypes.AuthNProvider]authn.AuthN
|
||||
userSetter user.Setter
|
||||
userGetter user.Getter
|
||||
authDomain authdomain.Module
|
||||
tokenizer tokenizer.Tokenizer
|
||||
orgGetter organization.Getter
|
||||
authz authz.AuthZ
|
||||
}
|
||||
|
||||
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ, globalConfig global.Config) session.Module {
|
||||
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, userSetter user.Setter, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
|
||||
return &module{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
|
||||
authNs: authNs,
|
||||
userSetter: userSetter,
|
||||
userGetter: userGetter,
|
||||
authDomain: authDomain,
|
||||
tokenizer: tokenizer,
|
||||
orgGetter: orgGetter,
|
||||
authz: authz,
|
||||
globalConfig: globalConfig,
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
|
||||
authNs: authNs,
|
||||
userSetter: userSetter,
|
||||
userGetter: userGetter,
|
||||
authDomain: authDomain,
|
||||
tokenizer: tokenizer,
|
||||
orgGetter: orgGetter,
|
||||
authz: authz,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,10 +140,6 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
|
||||
return "", err
|
||||
}
|
||||
|
||||
if callbackIdentity.State.URL.Host != "" && !module.globalConfig.IsOriginAllowed(callbackIdentity.State.URL) {
|
||||
return "", errors.Newf(errors.TypeForbidden, global.ErrCodeOriginNotAllowed, "state redirect %q is not an allowed origin", callbackIdentity.State.URL.String())
|
||||
}
|
||||
|
||||
authDomain, err := module.authDomain.GetByOrgIDAndID(ctx, callbackIdentity.OrgID, callbackIdentity.State.DomainID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -224,10 +217,6 @@ func (module *module) getOrgSessionContext(ctx context.Context, org *types.Organ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !module.globalConfig.IsOriginAllowed(siteURL) {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, global.ErrCodeOriginNotAllowed, "ref %q is not an allowed origin", siteURL.String())
|
||||
}
|
||||
|
||||
loginURL, err := provider.LoginURL(ctx, siteURL, authDomain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -327,11 +327,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
|
||||
|
||||
@@ -3,6 +3,7 @@ package querybuilder
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -13,8 +14,38 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var telemetryGrantKeys = map[string]struct{}{
|
||||
"service.name": {},
|
||||
}
|
||||
|
||||
const telemetryValueSafeBytes = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-"
|
||||
|
||||
func EscapeTelemetryValue(value string) string {
|
||||
var escaped strings.Builder
|
||||
for _, character := range []byte(value) {
|
||||
if strings.IndexByte(telemetryValueSafeBytes, character) >= 0 {
|
||||
escaped.WriteByte(character)
|
||||
continue
|
||||
}
|
||||
escaped.WriteString(fmt.Sprintf("%%%02X", character))
|
||||
}
|
||||
|
||||
return escaped.String()
|
||||
}
|
||||
|
||||
func TelemetrySelector(_ context.Context, resource coretypes.Resource, id string, _ valuer.UUID) ([]coretypes.Selector, error) {
|
||||
values := telemetrytypes.NewTelemetryGrantSelectors(id)
|
||||
values := []string{id}
|
||||
segments := strings.Split(id, "/")
|
||||
for level := len(segments) - 1; level >= 1; level-- {
|
||||
value := strings.Join(segments[:level], "/") + "/" + coretypes.WildCardSelectorString
|
||||
if value == id {
|
||||
continue
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if id != coretypes.WildCardSelectorString {
|
||||
values = append(values, coretypes.WildCardSelectorString)
|
||||
}
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(values))
|
||||
for _, value := range values {
|
||||
@@ -156,14 +187,14 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
continue
|
||||
}
|
||||
|
||||
key, ok := telemetrytypes.NewTelemetryGrantKey(condition.Key)
|
||||
key, ok := canonicalTelemetryGrantKey(condition.Key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if condition.Operator == "=" || condition.Operator == "IN" {
|
||||
for _, value := range condition.Values {
|
||||
ids = append(ids, queryType+"/"+key+"/"+value)
|
||||
ids = append(ids, queryType+"/"+key+"/"+EscapeTelemetryValue(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,3 +205,16 @@ func builderQuerySelectors(queryType, expression string, variables map[string]qb
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func canonicalTelemetryGrantKey(keyText string) (string, bool) {
|
||||
fieldKey := telemetrytypes.GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.FieldContext != telemetrytypes.FieldContextUnspecified && fieldKey.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
@@ -21,33 +22,33 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
expected []coretypes.ResourceWithID
|
||||
}{
|
||||
{
|
||||
name: "top level key equality",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'checkout' AND status = 500"),
|
||||
name: "top level service equality",
|
||||
body: builderQueryBody("logs", "service.name = 'checkout' AND status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource prefixed key",
|
||||
body: builderQueryBody("traces", "resource.signoz.workspace.key.id = 'checkout'"),
|
||||
name: "resource prefixed service key",
|
||||
body: builderQueryBody("traces", "resource.service.name = 'checkout'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "in atom requires every value",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id IN ('b', 'a')"),
|
||||
body: builderQueryBody("logs", "service.name IN ('b', 'a')"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple equality atoms each require a grant",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'b' AND signoz.workspace.key.id = 'a'"),
|
||||
body: builderQueryBody("logs", "service.name = 'b' AND service.name = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/b"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -58,38 +59,38 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'a' OR status = 500"),
|
||||
name: "service atom under or does not qualify",
|
||||
body: builderQueryBody("logs", "service.name = 'a' OR status = 500"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negated key atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT signoz.workspace.key.id = 'a'"),
|
||||
name: "negated service atom does not qualify",
|
||||
body: builderQueryBody("logs", "NOT service.name = 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key inequality does not qualify",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id != 'a'"),
|
||||
name: "service inequality does not qualify",
|
||||
body: builderQueryBody("logs", "service.name != 'a'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/*"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value with spaces and slashes stays plaintext in the id",
|
||||
body: builderQueryBody("logs", "signoz.workspace.key.id = 'check out/2'"),
|
||||
name: "unsafe value bytes are escaped",
|
||||
body: builderQueryBody("logs", "service.name = 'check out/2'"),
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/check out/2"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/check%20out%2F2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit source maps to audit logs resource",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"signoz.workspace.key.id = 'a'"}}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","source":"audit","filter":{"expression":"service.name = 'a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceAuditLogs, ID: "builder_query/service.name/a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -116,23 +117,23 @@ func TestQueryRangeResources(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "trace operator rides on its referenced queries",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"signoz.workspace.key.id = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"name":"A","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout'"}}},{"type":"builder_query","spec":{"name":"B","signal":"traces","disabled":true,"filter":{"expression":"service.name = 'checkout' AND has_error = true"}}},{"type":"builder_trace_operator","spec":{"name":"T1","expression":"A => B","returnSpansFrom":"A"}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/service.name/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "variable substitution qualifies",
|
||||
body: `{"variables":{"key":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = $key"}}}]}}`,
|
||||
body: `{"variables":{"svc":{"value":"checkout"}},"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = $svc"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/checkout"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/checkout"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duplicate queries dedupe",
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"signoz.workspace.key.id='a'"}}}]}}`,
|
||||
body: `{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name = 'a'"}}},{"type":"builder_query","spec":{"signal":"logs","filter":{"expression":"service.name='a'"}}}]}}`,
|
||||
expected: []coretypes.ResourceWithID{
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/signoz.workspace.key.id/a"},
|
||||
{Resource: coretypes.ResourceTelemetryResourceLogs, ID: "builder_query/service.name/a"},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -150,7 +151,7 @@ func TestQueryRangeResourcesErrors(t *testing.T) {
|
||||
bodies := []string{
|
||||
`{"compositeQuery":{"queries":[]}}`,
|
||||
`{}`,
|
||||
builderQueryBody("logs", "signoz.workspace.key.id = "),
|
||||
builderQueryBody("logs", "service.name = "),
|
||||
`{"compositeQuery":{"queries":[{"type":"builder_query","spec":{"signal":"unknown"}}]}}`,
|
||||
`{"compositeQuery":{"queries":[{"type":"unknown_type"}]}}`,
|
||||
}
|
||||
@@ -174,10 +175,10 @@ func TestTelemetrySelector(t *testing.T) {
|
||||
return values
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql"))
|
||||
assert.Equal(t, []string{"*"}, selectorValues("*"))
|
||||
// a value containing "/" stays one logical segment (SplitN 3).
|
||||
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
|
||||
assert.Equal(t, []string{"builder_query/service.name/a", "builder_query/service.name/*", "builder_query/*", "*"}, selectorValues("builder_query/service.name/a"))
|
||||
assert.Equal(t, []string{"builder_query/*", "*"}, selectorValues("builder_query/*"))
|
||||
assert.Equal(t, []string{"promql/*", "*"}, selectorValues("promql/*"))
|
||||
|
||||
_, err := TelemetrySelector(context.Background(), coretypes.ResourceTelemetryResourceLogs, strings.Repeat("a", 256), orgID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func NewModules(
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
|
||||
@@ -162,28 +162,18 @@ func (c *conditionBuilder) ConditionFor(
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
keys := querybuilder.MatchingFieldKeys(key, fieldKeys)
|
||||
keys, warning := querybuilder.ResolveKeys(key, querybuilder.MatchingFieldKeys(key, fieldKeys))
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
if _, isColumn := timeSeriesV4Columns[key.Name]; isColumn {
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{key}
|
||||
} else {
|
||||
if len(fieldKeys[key.Name]) == 0 {
|
||||
warnings = append(warnings, fmt.Sprintf("label `%s` not found in metadata; check the label name for typos", key.Name))
|
||||
}
|
||||
keys = []*telemetrytypes.TelemetryFieldKey{
|
||||
telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType),
|
||||
}
|
||||
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
keys = append(keys, telemetrytypes.NewTelemetryFieldKey(
|
||||
key.FieldContext.StringValue()+"."+key.Name, telemetrytypes.FieldContextAttribute, key.FieldDataType))
|
||||
}
|
||||
}
|
||||
return nil, warnings, querybuilder.NewKeyNotFoundError(key.Name)
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
cond, err := c.conditionForKey(ctx, orgID, startNs, endNs, k, operator, value, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -191,3 +181,21 @@ func (c *conditionBuilder) ConditionFor(
|
||||
}
|
||||
return conds, warnings, nil
|
||||
}
|
||||
|
||||
func (c *conditionBuilder) conditionForKey(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) (string, error) {
|
||||
condition, err := c.conditionFor(ctx, orgID, startNs, endNs, key, operator, value, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return condition, nil
|
||||
}
|
||||
|
||||
@@ -307,86 +307,3 @@ func TestConditionForMultipleKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionForKeyNotInMetadata(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
expectedSQL []string
|
||||
expectWarn bool
|
||||
}{
|
||||
{
|
||||
name: "intrinsic metric_name full-text resolves without warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "metric_name", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorRegexp,
|
||||
value: "k8s",
|
||||
expectedSQL: []string{"match(metric_name, ?)"},
|
||||
expectWarn: false,
|
||||
},
|
||||
{
|
||||
name: "unknown label resolves to labels extract with a typo warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextUnspecified},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "bar",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "context prefix that may be part of the name tries both readings",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "a.b.c", FieldContext: telemetrytypes.FieldContextScope},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "x",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'a.b.c') = ?", "JSONExtractString(labels, 'scope.a.b.c') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "unresolved metric-context name is treated as a label prefix",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "foo", FieldContext: telemetrytypes.FieldContextMetric},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "bar",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'foo') = ?", "JSONExtractString(labels, 'metric.foo') = ?"},
|
||||
expectWarn: true,
|
||||
},
|
||||
{
|
||||
name: "known label under a mismatched context collapses without warning",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "region", FieldContext: telemetrytypes.FieldContextResource},
|
||||
fieldKeys: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"region": {{Name: "region", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString}},
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "us",
|
||||
expectedSQL: []string{"JSONExtractString(labels, 'region') = ?"},
|
||||
expectWarn: false,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
conditionBuilder := NewConditionBuilder(fm)
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
cond, warnings, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, tc.fieldKeys, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(cond...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
for _, want := range tc.expectedSQL {
|
||||
assert.Contains(t, sql, want)
|
||||
}
|
||||
if tc.expectWarn {
|
||||
assert.NotEmpty(t, warnings)
|
||||
} else {
|
||||
assert.Empty(t, warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -107,6 +106,10 @@ func NewGettableTransaction(results []*TransactionWithAuthorization) []*Gettable
|
||||
return gettableTransactions
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) Diff(desired TransactionGroups) (additions, deletions TransactionGroups) {
|
||||
return desired.subtract(groups), groups.subtract(desired)
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) Value() (driver.Value, error) {
|
||||
data, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
@@ -165,6 +168,51 @@ func (transaction *Transaction) TransactionKey() string {
|
||||
return transaction.Relation.StringValue() + ":" + transaction.Object.Resource.Type.StringValue() + ":" + transaction.Object.Resource.Kind.String()
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) subtract(other TransactionGroups) TransactionGroups {
|
||||
otherSelectors := other.selectorSet()
|
||||
|
||||
order := make([]string, 0)
|
||||
grouped := make(map[string]*TransactionGroup)
|
||||
for _, group := range groups {
|
||||
for _, selector := range group.ObjectGroup.Selectors {
|
||||
if _, ok := otherSelectors[group.selectorKey(selector)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
groupKey := group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String()
|
||||
out, ok := grouped[groupKey]
|
||||
if !ok {
|
||||
out = &TransactionGroup{Relation: group.Relation, ObjectGroup: coretypes.ObjectGroup{Resource: group.ObjectGroup.Resource, Selectors: make([]coretypes.Selector, 0)}}
|
||||
grouped[groupKey] = out
|
||||
order = append(order, groupKey)
|
||||
}
|
||||
out.ObjectGroup.Selectors = append(out.ObjectGroup.Selectors, selector)
|
||||
}
|
||||
}
|
||||
|
||||
result := make(TransactionGroups, 0, len(order))
|
||||
for _, key := range order {
|
||||
result = append(result, grouped[key])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (groups TransactionGroups) selectorSet() map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, group := range groups {
|
||||
for _, selector := range group.ObjectGroup.Selectors {
|
||||
set[group.selectorKey(selector)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
|
||||
func (group *TransactionGroup) selectorKey(selector coretypes.Selector) string {
|
||||
return group.Relation.StringValue() + "|" + group.ObjectGroup.Resource.String() + "|" + selector.String()
|
||||
}
|
||||
|
||||
func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup, error) {
|
||||
verb, err := coretypes.NewVerb(raw.Relation)
|
||||
if err != nil {
|
||||
@@ -188,13 +236,6 @@ func newTransactionGroup(raw rawTransactionGroup, index int) (*TransactionGroup,
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(raw.ObjectGroup.Selectors))
|
||||
for selectorIndex, rawSelector := range raw.ObjectGroup.Selectors {
|
||||
if resourceType.Equals(coretypes.TypeTelemetryResource) {
|
||||
rawSelector, err = telemetrytypes.NewTelemetryGrantSelector(rawSelector)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
selector, err := resourceType.Selector(rawSelector)
|
||||
if err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "transactionGroups[%d].objectGroup.selectors[%d]: %s", index, selectorIndex, err.Error())
|
||||
|
||||
@@ -3,7 +3,6 @@ package authtypes
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
)
|
||||
@@ -69,38 +68,6 @@ func NewTuplesFromTransactionGroups(name string, orgID valuer.UUID, transactionG
|
||||
return tuples, nil
|
||||
}
|
||||
|
||||
func DiffTuples(existing, desired []*openfgav1.TupleKey) (additions, deletions []*openfgav1.TupleKey) {
|
||||
key := func(tuple *openfgav1.TupleKey) string {
|
||||
return tuple.GetUser() + "|" + tuple.GetRelation() + "|" + tuple.GetObject()
|
||||
}
|
||||
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, tuple := range existing {
|
||||
existingSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
desiredSet := make(map[string]struct{}, len(desired))
|
||||
for _, tuple := range desired {
|
||||
desiredSet[key(tuple)] = struct{}{}
|
||||
}
|
||||
|
||||
additions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range desired {
|
||||
if _, ok := existingSet[key(tuple)]; !ok {
|
||||
additions = append(additions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
deletions = make([]*openfgav1.TupleKey, 0)
|
||||
for _, tuple := range existing {
|
||||
if _, ok := desiredSet[key(tuple)]; !ok {
|
||||
deletions = append(deletions, tuple)
|
||||
}
|
||||
}
|
||||
|
||||
return additions, deletions
|
||||
}
|
||||
|
||||
func MustNewTransactionGroupsFromTuples(tuples []*openfgav1.TupleKey) TransactionGroups {
|
||||
objectsByRelation := make(map[string][]*coretypes.Object)
|
||||
|
||||
@@ -142,29 +109,17 @@ func NewTuplesFromTransactionsWithCorrelations(transactions []*Transaction, subj
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectorStrings, err := newCheckSelectors(txn.Object.Resource.Type, txn.Object.Selector)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
selectors := make([]coretypes.Selector, 0, len(selectorStrings))
|
||||
for _, selectorString := range selectorStrings {
|
||||
selector, err := txn.Object.Resource.Type.Selector(selectorString)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
selectors = append(selectors, selector)
|
||||
}
|
||||
|
||||
txnID := txn.ID.StringValue()
|
||||
for index, tuple := range NewTuples(resource, subject, txn.Relation, selectors, orgID) {
|
||||
if index == 0 {
|
||||
tuples[txnID] = tuple
|
||||
continue
|
||||
}
|
||||
|
||||
txnTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{txn.Object.Selector}, orgID)
|
||||
tuples[txnID] = txnTuples[0]
|
||||
|
||||
if txn.Object.Selector.String() != coretypes.WildCardSelectorString {
|
||||
wildcardSelector := txn.Object.Resource.Type.MustSelector(coretypes.WildCardSelectorString)
|
||||
wildcardTuples := NewTuples(resource, subject, txn.Relation, []coretypes.Selector{wildcardSelector}, orgID)
|
||||
|
||||
correlationID := valuer.GenerateUUID().StringValue()
|
||||
tuples[correlationID] = tuple
|
||||
tuples[correlationID] = wildcardTuples[0]
|
||||
correlations[txnID] = append(correlations[txnID], correlationID)
|
||||
}
|
||||
}
|
||||
@@ -259,21 +214,3 @@ func NewTransactionWithAuthorizationFromBatchResults(
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func newCheckSelectors(resourceType coretypes.Type, selector coretypes.Selector) ([]string, error) {
|
||||
if resourceType.Equals(coretypes.TypeTelemetryResource) {
|
||||
canonical, err := telemetrytypes.NewTelemetryGrantSelector(selector.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return telemetrytypes.NewTelemetryGrantSelectors(canonical), nil
|
||||
}
|
||||
|
||||
selectorStrings := []string{selector.String()}
|
||||
if selector.String() != coretypes.WildCardSelectorString {
|
||||
selectorStrings = append(selectorStrings, coretypes.WildCardSelectorString)
|
||||
}
|
||||
|
||||
return selectorStrings, nil
|
||||
}
|
||||
|
||||
@@ -23,5 +23,5 @@ var (
|
||||
TypeRole = Type{valuer.NewString("role"), regexp.MustCompile(`^([a-z-]{1,50}|\*)$`), []Verb{VerbAssignee, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeOrganization = Type{valuer.NewString("organization"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbRead, VerbUpdate}}
|
||||
TypeMetaResource = Type{valuer.NewString("metaresource"), regexp.MustCompile(`^(^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$|\*)$`), []Verb{VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete, VerbAttach, VerbDetach}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^.{1,512}$`), []Verb{VerbRead}}
|
||||
TypeTelemetryResource = Type{valuer.NewString("telemetryresource"), regexp.MustCompile(`^(\*|[a-z_]{1,32}(/(\*|[A-Za-z0-9._%-]{1,128})){0,2})$`), []Verb{VerbRead}}
|
||||
)
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package coretypes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -29,18 +26,7 @@ func (resourceTelemetryResource *resourceTelemetryResource) Prefix(orgID valuer.
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Object(orgID valuer.UUID, selector string) string {
|
||||
if selector == WildCardSelectorString {
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
}
|
||||
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + telemetrySelectorHash(selector)
|
||||
}
|
||||
|
||||
// Must stay stable: grant-time and check-time tuple objects both hash the selector
|
||||
// here, so changing this invalidates every stored telemetry grant tuple.
|
||||
func telemetrySelectorHash(selector string) string {
|
||||
sum := sha256.Sum256([]byte(selector))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
return resourceTelemetryResource.Prefix(orgID) + "/" + selector
|
||||
}
|
||||
|
||||
func (resourceTelemetryResource *resourceTelemetryResource) Scope(verb Verb) string {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
const wildcardSelector = "*"
|
||||
|
||||
var telemetryGrantQueryTypes = map[string]bool{
|
||||
"builder_query": true,
|
||||
"builder_sub_query": true,
|
||||
"promql": false,
|
||||
"clickhouse_sql": false,
|
||||
}
|
||||
|
||||
var telemetryGrantKeys = map[string]struct{}{
|
||||
"signoz.workspace.key.id": {},
|
||||
}
|
||||
|
||||
func NewTelemetryGrantKey(keyText string) (string, bool) {
|
||||
fieldKey := GetFieldKeyFromKeyText(keyText)
|
||||
if fieldKey.FieldContext != FieldContextUnspecified && fieldKey.FieldContext != FieldContextResource {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if _, ok := telemetryGrantKeys[fieldKey.Name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return fieldKey.Name, true
|
||||
}
|
||||
|
||||
func NewTelemetryGrantSelector(input string) (string, error) {
|
||||
if input == wildcardSelector {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, "/", 3)
|
||||
|
||||
keyScoped, ok := telemetryGrantQueryTypes[parts[0]]
|
||||
if !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must start with a supported query type or be %q", input, wildcardSelector)
|
||||
}
|
||||
queryType := parts[0]
|
||||
|
||||
if len(parts) < 3 {
|
||||
if len(parts) == 2 && parts[1] != wildcardSelector {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must be <query_type>, <query_type>/*, <query_type>/<key>/* or <query_type>/<key>/<value>", input)
|
||||
}
|
||||
return queryType + "/" + wildcardSelector, nil
|
||||
}
|
||||
|
||||
if !keyScoped {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q is invalid: query type %q supports only %q or %q", input, queryType, queryType, queryType+"/"+wildcardSelector)
|
||||
}
|
||||
|
||||
key, ok := NewTelemetryGrantKey(parts[1])
|
||||
if !ok {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a supported key: %s", input, strings.Join(telemetryGrantKeyNames(), ", "))
|
||||
}
|
||||
|
||||
value := parts[2]
|
||||
if value == wildcardSelector {
|
||||
return queryType + "/" + key + "/" + wildcardSelector, nil
|
||||
}
|
||||
if value == "" || strings.HasPrefix(value, "$") {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "telemetry selector %q must use a concrete non-empty value", input)
|
||||
}
|
||||
|
||||
return queryType + "/" + key + "/" + value, nil
|
||||
}
|
||||
|
||||
func NewTelemetryGrantSelectors(selector string) []string {
|
||||
if selector == wildcardSelector {
|
||||
return []string{wildcardSelector}
|
||||
}
|
||||
|
||||
parts := strings.SplitN(selector, "/", 3)
|
||||
queryType := parts[0]
|
||||
|
||||
if len(parts) < 3 {
|
||||
return []string{queryType + "/" + wildcardSelector, wildcardSelector}
|
||||
}
|
||||
|
||||
key, value := parts[1], parts[2]
|
||||
if value == wildcardSelector {
|
||||
return []string{
|
||||
queryType + "/" + key + "/" + wildcardSelector,
|
||||
queryType + "/" + wildcardSelector,
|
||||
wildcardSelector,
|
||||
}
|
||||
}
|
||||
|
||||
return []string{
|
||||
queryType + "/" + key + "/" + value,
|
||||
queryType + "/" + key + "/" + wildcardSelector,
|
||||
queryType + "/" + wildcardSelector,
|
||||
wildcardSelector,
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryGrantKeyNames() []string {
|
||||
names := make([]string, 0, len(telemetryGrantKeys))
|
||||
for name := range telemetryGrantKeys {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package telemetrytypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewTelemetryGrantSelector(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"*": "*",
|
||||
"builder_query": "builder_query/*",
|
||||
"builder_query/*": "builder_query/*",
|
||||
"promql": "promql/*",
|
||||
"clickhouse_sql": "clickhouse_sql/*",
|
||||
"builder_query/signoz.workspace.key.id/*": "builder_query/signoz.workspace.key.id/*",
|
||||
"builder_query/signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/resource.signoz.workspace.key.id/key-a": "builder_query/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id/key a": "builder_query/signoz.workspace.key.id/key a",
|
||||
"builder_query/signoz.workspace.key.id/a/b": "builder_query/signoz.workspace.key.id/a/b",
|
||||
}
|
||||
for input, expected := range valid {
|
||||
canonical, err := NewTelemetryGrantSelector(input)
|
||||
require.NoError(t, err, "input %q", input)
|
||||
assert.Equal(t, expected, canonical, "input %q", input)
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
"key-a",
|
||||
"signoz.workspace.key.id = 'key-a'",
|
||||
"builder_trace_operator/signoz.workspace.key.id/key-a",
|
||||
"builder_query/service.name/frontend",
|
||||
"builder_query/signoz.workspace.key.id/",
|
||||
"builder_query/signoz.workspace.key.id/$svc",
|
||||
"*/signoz.workspace.key.id/key-a",
|
||||
"builder_query/signoz.workspace.key.id",
|
||||
"clickhouse_sql/signoz.workspace.key.id/key-a",
|
||||
"clickhouse_sql/signoz.workspace.key.id/*",
|
||||
"promql/signoz.workspace.key.id/key-a",
|
||||
}
|
||||
for _, input := range invalid {
|
||||
_, err := NewTelemetryGrantSelector(input)
|
||||
assert.Error(t, err, "input %q", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTelemetryGrantKey(t *testing.T) {
|
||||
valid := map[string]string{
|
||||
"signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
"resource.signoz.workspace.key.id": "signoz.workspace.key.id",
|
||||
}
|
||||
for keyText, expected := range valid {
|
||||
key, ok := NewTelemetryGrantKey(keyText)
|
||||
assert.True(t, ok, keyText)
|
||||
assert.Equal(t, expected, key, keyText)
|
||||
}
|
||||
|
||||
for _, keyText := range []string{"service.name", "attribute.signoz.workspace.key.id", "body.signoz.workspace.key.id"} {
|
||||
_, ok := NewTelemetryGrantKey(keyText)
|
||||
assert.False(t, ok, keyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTelemetryGrantSelectors(t *testing.T) {
|
||||
ladders := map[string][]string{
|
||||
"*": {"*"},
|
||||
"builder_query/*": {"builder_query/*", "*"},
|
||||
"promql/*": {"promql/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/*": {"builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/a": {"builder_query/signoz.workspace.key.id/a", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
"builder_query/signoz.workspace.key.id/a/b": {"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"},
|
||||
}
|
||||
for selector, expected := range ladders {
|
||||
assert.Equal(t, expected, NewTelemetryGrantSelectors(selector), "selector %q", selector)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
BIN
scripts/promqltestcorpus/promqltestcorpus
Executable file
BIN
scripts/promqltestcorpus/promqltestcorpus
Executable file
Binary file not shown.
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": {}
|
||||
}
|
||||
@@ -200,7 +200,6 @@ def test_hosts_warnings(
|
||||
{"prod-linux-1", "dev-linux-1"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("host.namee = 'prod-linux-1'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_hosts_filter(
|
||||
@@ -258,6 +257,7 @@ def test_hosts_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("host.namee = 'prod-linux-1'", "host.namee", id="bad_attr_name"),
|
||||
pytest.param("host.name =", None, id="trailing_op"),
|
||||
pytest.param("(host.name = 'prod-linux-1'", None, id="unclosed_paren"),
|
||||
# Cases dropped — parser is permissive and accepts these silently:
|
||||
@@ -274,8 +274,8 @@ def test_hosts_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -290,7 +290,6 @@ def test_pods_warnings(
|
||||
{"web-prod-1", "web-dev-1"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.pod.namee = 'web-prod-1'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_pods_filter(
|
||||
@@ -349,6 +348,7 @@ def test_pods_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.pod.namee = 'web-prod-1'", "k8s.pod.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.pod.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.pod.name = 'web-prod-1'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -361,8 +361,8 @@ def test_pods_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
_load_pods_metrics(
|
||||
|
||||
@@ -216,7 +216,6 @@ def test_nodes_warnings(
|
||||
{"web-a-us-1", "web-b-us-1"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.node.namee = 'web-a-us-1'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_nodes_filter(
|
||||
@@ -273,6 +272,7 @@ def test_nodes_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.node.namee = 'web-a-us-1'", "k8s.node.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.node.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.node.name = 'web-a-us-1'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -285,8 +285,8 @@ def test_nodes_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -215,7 +215,6 @@ def test_namespaces_warnings(
|
||||
{"web-a-prod", "web-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.namespace.namee = 'web-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_namespaces_filter(
|
||||
@@ -271,6 +270,7 @@ def test_namespaces_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.namespace.namee = 'web-a-prod'", "k8s.namespace.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.namespace.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.namespace.name = 'web-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -283,8 +283,8 @@ def test_namespaces_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -233,7 +233,6 @@ def test_clusters_warnings(
|
||||
{"web-gcp-prod", "web-aws-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.cluster.namee = 'web-gcp-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_clusters_filter(
|
||||
@@ -291,6 +290,7 @@ def test_clusters_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.cluster.namee = 'web-gcp-prod'", "k8s.cluster.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.cluster.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.cluster.name = 'web-gcp-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -303,8 +303,8 @@ def test_clusters_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -231,7 +231,6 @@ def test_volumes_warnings(
|
||||
{"data-ns-a-prod", "data-ns-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.persistentvolumeclaim.namee = 'data-ns-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_volumes_filter(
|
||||
@@ -290,6 +289,11 @@ def test_volumes_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param(
|
||||
"k8s.persistentvolumeclaim.namee = 'data-ns-a-prod'",
|
||||
"k8s.persistentvolumeclaim.namee",
|
||||
id="bad_attr_name",
|
||||
),
|
||||
pytest.param("k8s.persistentvolumeclaim.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.persistentvolumeclaim.name = 'data-ns-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -302,8 +306,8 @@ def test_volumes_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -242,7 +242,6 @@ def test_deployments_warnings(
|
||||
{"web-a-prod", "web-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.deployment.namee = 'web-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_deployments_filter(
|
||||
@@ -302,6 +301,7 @@ def test_deployments_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.deployment.namee = 'web-a-prod'", "k8s.deployment.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.deployment.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.deployment.name = 'web-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -314,8 +314,8 @@ def test_deployments_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -156,7 +156,6 @@ def test_statefulsets_accuracy(
|
||||
{"web-a-prod", "web-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.statefulset.namee = 'web-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_statefulsets_filter(
|
||||
@@ -216,6 +215,7 @@ def test_statefulsets_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.statefulset.namee = 'web-a-prod'", "k8s.statefulset.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.statefulset.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.statefulset.name = 'web-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -228,8 +228,8 @@ def test_statefulsets_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -246,7 +246,6 @@ def test_jobs_warnings(
|
||||
{"etl-a-prod", "etl-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.job.namee = 'etl-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_jobs_filter(
|
||||
@@ -305,6 +304,7 @@ def test_jobs_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.job.namee = 'etl-a-prod'", "k8s.job.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.job.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.job.name = 'etl-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -317,8 +317,8 @@ def test_jobs_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
@@ -162,7 +162,6 @@ def test_daemonsets_accuracy(
|
||||
{"logs-a-prod", "logs-b-prod"},
|
||||
id="in_contains",
|
||||
),
|
||||
pytest.param("k8s.daemonset.namee = 'logs-a-prod'", set(), id="unresolved_key"),
|
||||
],
|
||||
)
|
||||
def test_daemonsets_filter(
|
||||
@@ -222,6 +221,7 @@ def test_daemonsets_filter(
|
||||
@pytest.mark.parametrize(
|
||||
"expression,err_substr",
|
||||
[
|
||||
pytest.param("k8s.daemonset.namee = 'logs-a-prod'", "k8s.daemonset.namee", id="bad_attr_name"),
|
||||
pytest.param("k8s.daemonset.name =", None, id="trailing_op"),
|
||||
pytest.param("(k8s.daemonset.name = 'logs-a-prod'", None, id="unclosed_paren"),
|
||||
],
|
||||
@@ -234,8 +234,8 @@ def test_daemonsets_filter_invalid(
|
||||
expression: str,
|
||||
err_substr,
|
||||
) -> None:
|
||||
"""Malformed filter grammar (trailing operator, unclosed paren) returns
|
||||
400 invalid_input with structured errors."""
|
||||
"""Invalid filter expressions (typo'd attribute key, malformed grammar) return
|
||||
400 invalid_input with structured errors; bad attribute keys are named in them."""
|
||||
now = datetime.now(tz=UTC).replace(microsecond=0)
|
||||
insert_metrics(
|
||||
Metrics.load_from_file(
|
||||
|
||||
218
tests/integration/tests/promqlconformance/01_upstream_corpus.py
Normal file
218
tests/integration/tests/promqlconformance/01_upstream_corpus.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
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 _decode(v: float | str) -> float:
|
||||
if isinstance(v, str):
|
||||
return SPECIALS[v]
|
||||
return float(v)
|
||||
|
||||
|
||||
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 _labelset(labels: dict[str, str]) -> tuple:
|
||||
return tuple(sorted(labels.items()))
|
||||
|
||||
|
||||
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"]): _decode(v["value"]) for v in series.get("values") or []}
|
||||
key = _labelset(lbls)
|
||||
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 _decode(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: _decode(v) for off_ms, v in res["points"]}
|
||||
expected[_labelset(res["labels"])] = 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,145 @@
|
||||
"""
|
||||
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 _series_view(data: dict) -> list[tuple[dict, list]]:
|
||||
out = []
|
||||
for series in get_all_series(data, "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()))
|
||||
|
||||
|
||||
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]}"
|
||||
return _series_view(resp.json())
|
||||
|
||||
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}"
|
||||
@@ -19,8 +19,8 @@ from fixtures.querier import build_raw_query, get_column_data_from_response, mak
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
scoped_role = "telemetry-scope-key-a"
|
||||
scoped_email = "scope-key-a@telemetry.test"
|
||||
scoped_role = "telemetry-scope-svc-a"
|
||||
scoped_email = "scope-svc-a@telemetry.test"
|
||||
|
||||
|
||||
def test_setup(
|
||||
@@ -37,8 +37,8 @@ def test_setup(
|
||||
admin_token,
|
||||
scoped_role,
|
||||
[
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key-a"]),
|
||||
transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"]),
|
||||
transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/service-a"]),
|
||||
transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"]),
|
||||
],
|
||||
)
|
||||
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="VIEWER", password=user_password)
|
||||
@@ -48,13 +48,10 @@ def test_setup(
|
||||
@pytest.mark.parametrize(
|
||||
"selector",
|
||||
[
|
||||
"signoz.workspace.key.id = 'key-a'", # expression form, not the wire form
|
||||
"unknown_query_type/signoz.workspace.key.id/key-a", # unsupported query type
|
||||
"builder_query/service.name/frontend", # service.name is not a supported grant key
|
||||
"*/signoz.workspace.key.id/key-a", # non-prefix wildcard
|
||||
"builder_query/signoz.workspace.key.id/", # empty value
|
||||
"builder_query/signoz.workspace.key.id", # missing value, not a wildcard
|
||||
"clickhouse_sql/signoz.workspace.key.id/key-a", # clickhouse_sql does not support key-scoped selectors
|
||||
"service.name = 'service-a'", # expression form, not the wire form
|
||||
"builder_query/service.name/check out", # raw space
|
||||
"builder_query/service.name/'quoted'", # quote
|
||||
"builder_query/service.name/a/b/c", # too deep
|
||||
],
|
||||
)
|
||||
def test_invalid_telemetry_selector_rejected(
|
||||
@@ -78,10 +75,10 @@ def test_invalid_telemetry_selector_rejected(
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
"signoz.workspace.key.id = 'key-a'",
|
||||
"signoz.workspace.key.id IN ('key-a')",
|
||||
"resource.signoz.workspace.key.id = 'key-a'",
|
||||
"signoz.workspace.key.id = 'key-a' AND severity_text = 'ERROR'",
|
||||
"service.name = 'service-a'",
|
||||
"service.name IN ('service-a')",
|
||||
"resource.service.name = 'service-a'",
|
||||
"service.name = 'service-a' AND severity_text = 'ERROR'",
|
||||
],
|
||||
)
|
||||
def test_allowed(
|
||||
@@ -91,9 +88,9 @@ def test_allowed(
|
||||
expression: str,
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
# Seed a key-a log so the resource-attribute key resolves; without any
|
||||
# Seed a service-a log so the resource-attribute key resolves; without any
|
||||
# ingested data the querier rejects the filter with "key not found".
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0")])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
@@ -110,15 +107,15 @@ def test_allowed(
|
||||
"expression",
|
||||
[
|
||||
None, # no filter
|
||||
"signoz.workspace.key.id = 'key-b'",
|
||||
"signoz.workspace.key.id IN ('key-a', 'key-b')",
|
||||
"signoz.workspace.key.id = 'key-a' OR severity_text = 'ERROR'",
|
||||
"NOT signoz.workspace.key.id = 'key-a'",
|
||||
"signoz.workspace.key.id != 'key-b'",
|
||||
# Same result set as IN ('key-a','key-b'), but the OR spelling is not
|
||||
"service.name = 'service-b'",
|
||||
"service.name IN ('service-a', 'service-b')",
|
||||
"service.name = 'service-a' OR severity_text = 'ERROR'",
|
||||
"NOT service.name = 'service-a'",
|
||||
"service.name != 'service-b'",
|
||||
# Same result set as IN ('service-a','service-b'), but the OR spelling is not
|
||||
# yet recognized as a bounded set, so it is denied today. This flips to
|
||||
# allowed-with-both-grants once the where-clause bound evaluation lands.
|
||||
"signoz.workspace.key.id = 'key-a' OR signoz.workspace.key.id = 'key-b'",
|
||||
"service.name = 'service-a' OR service.name = 'service-b'",
|
||||
],
|
||||
)
|
||||
def test_denied(
|
||||
@@ -149,11 +146,11 @@ def test_denied_message_names_resource(
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-b'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
assert "builder_query/signoz.workspace.key.id/key-b" in response.text
|
||||
assert "builder_query/service.name/service-b" in response.text
|
||||
|
||||
|
||||
def test_variables_resolve_into_gate(
|
||||
@@ -162,7 +159,7 @@ def test_variables_resolve_into_gate(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0")])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0")])
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(scoped_email, user_password)
|
||||
|
||||
@@ -171,9 +168,9 @@ def test_variables_resolve_into_gate(
|
||||
token,
|
||||
start,
|
||||
end,
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = $key")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
|
||||
request_type="raw",
|
||||
variables={"key": {"value": "key-a"}},
|
||||
variables={"svc": {"value": "service-a"}},
|
||||
)
|
||||
assert allowed.status_code == HTTPStatus.OK, allowed.text
|
||||
|
||||
@@ -182,9 +179,9 @@ def test_variables_resolve_into_gate(
|
||||
token,
|
||||
start,
|
||||
end,
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = $key")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = $svc")],
|
||||
request_type="raw",
|
||||
variables={"key": {"value": "key-b"}},
|
||||
variables={"svc": {"value": "service-b"}},
|
||||
)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
|
||||
@@ -195,17 +192,17 @@ def test_returns_only_scoped_rows(
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"signoz.workspace.key.id": "key-a"}, body=f"key-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"signoz.workspace.key.id": "key-b"}, body=f"key-b-{i}") for i in range(3)])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-a"}, body=f"service-a-{i}") for i in range(3)] + [Logs(timestamp=now - timedelta(seconds=i + 1), resources={"service.name": "service-b"}, body=f"service-b-{i}") for i in range(3)])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(scoped_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-a'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bodies = get_column_data_from_response(response.json(), "body")
|
||||
assert bodies, "expected rows for key-a"
|
||||
assert all(body.startswith("key-a") for body in bodies), bodies
|
||||
assert bodies, "expected rows for service-a"
|
||||
assert all(body.startswith("service-a") for body in bodies), bodies
|
||||
|
||||
@@ -9,8 +9,8 @@ from fixtures.querier import build_raw_query, get_column_data_from_response, mak
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
any_key_role = "telemetry-scope-any-key"
|
||||
any_key_email = "scope-any-key@telemetry.test"
|
||||
any_service_role = "telemetry-scope-any-service"
|
||||
any_service_email = "scope-any-service@telemetry.test"
|
||||
builder_all_role = "telemetry-scope-builder-all"
|
||||
builder_all_email = "scope-builder-all@telemetry.test"
|
||||
|
||||
@@ -23,16 +23,16 @@ def test_setup(
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
create_role(admin_token, any_key_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
|
||||
any_user = create_active_user(signoz, admin_token, email=any_key_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_key_role)
|
||||
create_role(admin_token, any_service_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/*"])])
|
||||
any_user = create_active_user(signoz, admin_token, email=any_service_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_service_role)
|
||||
|
||||
create_role(admin_token, builder_all_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])])
|
||||
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, all_user, "signoz-viewer", builder_all_role)
|
||||
|
||||
|
||||
def test_key_wildcard_allows_any_single_key(
|
||||
def test_service_wildcard_allows_any_single_service(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
@@ -40,28 +40,28 @@ def test_key_wildcard_allows_any_single_key(
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-b"}, body="key-b-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
|
||||
]
|
||||
)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(any_key_email, user_password)
|
||||
token = get_token(any_service_email, user_password)
|
||||
|
||||
key_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-a'")], request_type="raw")
|
||||
assert key_a.status_code == HTTPStatus.OK, key_a.text
|
||||
service_a = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-a'")], request_type="raw")
|
||||
assert service_a.status_code == HTTPStatus.OK, service_a.text
|
||||
|
||||
key_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'key-b'")], request_type="raw")
|
||||
assert key_b.status_code == HTTPStatus.OK, key_b.text
|
||||
service_b = make_query_request(signoz, token, start, end, [build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'service-b'")], request_type="raw")
|
||||
assert service_b.status_code == HTTPStatus.OK, service_b.text
|
||||
|
||||
|
||||
def test_key_wildcard_denies_unfiltered(
|
||||
def test_service_wildcard_denies_unfiltered(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(any_key_email, user_password),
|
||||
get_token(any_service_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50)],
|
||||
@@ -86,7 +86,7 @@ def test_builder_wildcard_allows_unfiltered(
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
def test_admin_allows_unfiltered_across_keys(
|
||||
def test_admin_allows_unfiltered_across_services(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
@@ -94,8 +94,8 @@ def test_admin_allows_unfiltered_across_keys(
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs(
|
||||
[
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-a"}, body="key-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": "key-b"}, body="key-b-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-a"}, body="service-a-0"),
|
||||
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "service-b"}, body="service-b-0"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -109,4 +109,4 @@ def test_admin_allows_unfiltered_across_keys(
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
bodies = get_column_data_from_response(response.json(), "body")
|
||||
assert any(body.startswith("key-b") for body in bodies), bodies
|
||||
assert any(body.startswith("service-b") for body in bodies), bodies
|
||||
|
||||
@@ -10,8 +10,8 @@ from fixtures.role import transaction_group
|
||||
user_password = "password123Z$"
|
||||
chsql_role = "telemetry-scope-chsql"
|
||||
chsql_email = "scope-chsql@telemetry.test"
|
||||
key_a_role = "telemetry-qt-key-a"
|
||||
key_a_email = "qt-key-a@telemetry.test"
|
||||
svc_a_role = "telemetry-qt-svc-a"
|
||||
svc_a_email = "qt-svc-a@telemetry.test"
|
||||
viewer_email = "qt-managed-viewer@telemetry.test"
|
||||
|
||||
clickhouse_query = [{"type": "clickhouse_sql", "spec": {"name": "A", "query": "SELECT toFloat64(1.5) AS `__result_0`", "disabled": False}}]
|
||||
@@ -41,9 +41,9 @@ def test_setup(
|
||||
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, chsql_user, "signoz-viewer", chsql_role)
|
||||
|
||||
create_role(admin_token, key_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"])])
|
||||
key_a_user = create_active_user(signoz, admin_token, email=key_a_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, key_a_user, "signoz-viewer", key_a_role)
|
||||
create_role(admin_token, svc_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/service.name/service-a"])])
|
||||
svc_a_user = create_active_user(signoz, admin_token, email=svc_a_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, svc_a_user, "signoz-viewer", svc_a_role)
|
||||
|
||||
# A plain managed viewer (signoz-viewer) — for the meter-metrics/audit-logs policy checks.
|
||||
create_active_user(signoz, admin_token, email=viewer_email, role="VIEWER", password=user_password)
|
||||
@@ -59,7 +59,7 @@ def test_clickhouse_sql_requires_chsql_grant(
|
||||
granted = make_query_request(signoz, get_token(chsql_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
assert granted.status_code == HTTPStatus.OK, granted.text
|
||||
|
||||
scoped = make_query_request(signoz, get_token(key_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
|
||||
|
||||
admin = make_query_request(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), start, end, clickhouse_query, request_type=querier.RequestType.SCALAR)
|
||||
@@ -73,8 +73,8 @@ def test_promql_requires_promql_grant(
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(hours=1)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
|
||||
# Neither the chsql grant nor a builder-key grant covers promql.
|
||||
scoped = make_query_request(signoz, get_token(key_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
|
||||
# Neither the chsql grant nor a builder-service grant covers promql.
|
||||
scoped = make_query_request(signoz, get_token(svc_a_email, user_password), start, end, promql_query, request_type=querier.RequestType.TIME_SERIES)
|
||||
assert scoped.status_code == HTTPStatus.FORBIDDEN, scoped.text
|
||||
|
||||
# Admin holds the wildcard; authz passes (the handler may still 2xx/4xx, never 403).
|
||||
@@ -88,19 +88,19 @@ def test_trace_operator_rides_on_referenced_queries(
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(key_a_email, user_password)
|
||||
token = get_token(svc_a_email, user_password)
|
||||
|
||||
def operator_queries(b_key: str) -> list[dict]:
|
||||
def operator_queries(b_service: str) -> list[dict]:
|
||||
return [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "signoz.workspace.key.id = 'key-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"signoz.workspace.key.id = '{b_key}'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": True, "filter": {"expression": f"service.name = '{b_service}'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_trace_operator", "spec": {"name": "T1", "expression": "A => B", "returnSpansFrom": "A", "disabled": False}},
|
||||
]
|
||||
|
||||
allowed = make_query_request(signoz, token, start, end, operator_queries("key-a"), request_type=querier.RequestType.RAW)
|
||||
allowed = make_query_request(signoz, token, start, end, operator_queries("service-a"), request_type=querier.RequestType.RAW)
|
||||
assert allowed.status_code == HTTPStatus.OK, allowed.text
|
||||
|
||||
denied = make_query_request(signoz, token, start, end, operator_queries("key-b"), request_type=querier.RequestType.RAW)
|
||||
denied = make_query_request(signoz, token, start, end, operator_queries("service-b"), request_type=querier.RequestType.RAW)
|
||||
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
|
||||
|
||||
|
||||
@@ -110,14 +110,14 @@ def test_formula_rides_on_referenced_queries(
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
|
||||
token = get_token(key_a_email, user_password)
|
||||
token = get_token(svc_a_email, user_password)
|
||||
|
||||
def formula_queries(b_filtered: bool) -> list[dict]:
|
||||
b_spec = {"name": "B", "signal": "traces", "disabled": True, "aggregations": [{"expression": "count()"}]}
|
||||
if b_filtered:
|
||||
b_spec["filter"] = {"expression": "signoz.workspace.key.id = 'key-a'"}
|
||||
b_spec["filter"] = {"expression": "service.name = 'service-a'"}
|
||||
return [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "signoz.workspace.key.id = 'key-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": True, "filter": {"expression": "service.name = 'service-a'"}, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_query", "spec": b_spec},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A/B", "disabled": False}},
|
||||
]
|
||||
|
||||
@@ -11,9 +11,8 @@ from fixtures.role import transaction_group
|
||||
user_password = "password123Z$"
|
||||
spacey_role = "telemetry-scope-spacey"
|
||||
spacey_email = "scope-spacey@telemetry.test"
|
||||
# The grant value has a space; it is stored plaintext in the role record and hashed
|
||||
# into the tuple, so a matching query must round-trip the exact value.
|
||||
spacey_value = "key with space"
|
||||
# The service name has a space; its canonical selector escapes it to %20.
|
||||
spacey_service = "check out"
|
||||
|
||||
|
||||
def test_setup(
|
||||
@@ -23,31 +22,31 @@ def test_setup(
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key with space"])])
|
||||
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/service.name/check%20out"])])
|
||||
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", spacey_role)
|
||||
|
||||
|
||||
def test_escaped_value_parity_allows_matching_value(
|
||||
def test_escaped_value_parity_allows_matching_service(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"signoz.workspace.key.id": spacey_value}, body="spacey-0")])
|
||||
insert_logs([Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": spacey_service}, body="spacey-0")])
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(spacey_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=f"signoz.workspace.key.id = '{spacey_value}'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression=f"service.name = '{spacey_service}'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
|
||||
def test_escaped_value_denies_other_value(
|
||||
def test_escaped_value_denies_other_service(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
@@ -57,7 +56,7 @@ def test_escaped_value_denies_other_value(
|
||||
get_token(spacey_email, user_password),
|
||||
int((now - timedelta(minutes=10)).timestamp() * 1000),
|
||||
int(now.timestamp() * 1000),
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="signoz.workspace.key.id = 'keywithspace'")],
|
||||
[build_raw_query("A", "logs", limit=50, filter_expression="service.name = 'checkout'")],
|
||||
request_type="raw",
|
||||
)
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, change_user_role, create_active_user
|
||||
from fixtures.role import transaction_group
|
||||
|
||||
user_password = "password123Z$"
|
||||
keywild_role = "telemetry-check-keywild"
|
||||
keywild_email = "check-keywild@telemetry.test"
|
||||
|
||||
|
||||
def test_setup(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_role: Callable[..., str],
|
||||
) -> None:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
create_role(admin_token, keywild_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
|
||||
user_id = create_active_user(signoz, admin_token, email=keywild_email, role="VIEWER", password=user_password)
|
||||
change_user_role(signoz, admin_token, user_id, "signoz-viewer", keywild_role)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "authorized"),
|
||||
[
|
||||
("builder_query/signoz.workspace.key.id/key-a", True), # concrete value resolves up the ladder to the key wildcard grant
|
||||
("builder_query/signoz.workspace.key.id/*", True), # exact grant
|
||||
("builder_query/resource.signoz.workspace.key.id/key-a", True), # resource.signoz.workspace.key.id folds before laddering
|
||||
("promql/*", False), # different query type never reaches the builder_query grant
|
||||
],
|
||||
)
|
||||
def test_check_ladders_to_key_wildcard_grant(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
selector: str,
|
||||
authorized: bool,
|
||||
) -> None:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/authz/check"),
|
||||
json=[{"relation": "read", "object": {"resource": {"type": "telemetryresource", "kind": "logs"}, "selector": selector}}],
|
||||
headers={"Authorization": f"Bearer {get_token(keywild_email, user_password)}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"][0]["authorized"] is authorized
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -66,9 +66,9 @@ def test_metrics_filter_label_context(
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
"""Metrics has no per-context storage: every label lives in the `labels` JSON, so a label
|
||||
*filter* collapses every context to JSONExtractString(labels,'region') just like group-by does.
|
||||
bare `region`, `attribute.region`, and `resource.region` are all equivalent and select `us`."""
|
||||
"""Unlike group-by (which collapses every context to labels), a label *filter* resolves via
|
||||
metadata under the label's registered (attribute) context: bare `region` and `attribute.region`
|
||||
are equivalent, but an explicit mismatched context (`resource.region`) is not found (400)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_metrics(
|
||||
[
|
||||
@@ -106,8 +106,7 @@ def test_metrics_filter_label_context(
|
||||
data = {row[0]: row[-1] for row in querier.get_scalar_table_data(response.json())}
|
||||
assert data == {"us": 30.0}, f"{expr}: {data}"
|
||||
|
||||
# resource. is a context the label is not registered under; metrics collapses it to the
|
||||
# same labels lookup, so it resolves rather than erroring.
|
||||
# resource. is a context the label is not registered under -> hard "not found".
|
||||
response = querier.make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
@@ -121,7 +120,7 @@ def test_metrics_filter_label_context(
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_metrics_group_by_unknown_label(
|
||||
@@ -209,50 +208,3 @@ def test_metrics_filter_unknown_label_matches_nothing(
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert querier.get_scalar_table_data(response.json()) == []
|
||||
assert querier.get_all_warnings(response.json()) == []
|
||||
|
||||
|
||||
def test_metrics_full_text_filter_does_not_error(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
"""A bare/quoted term has no key=value form, so the visitor routes it through the metrics
|
||||
full-text search column, which is never present in the metadata keys. The condition builder
|
||||
must resolve it (not hard-error) so the query runs. Regression: a partial filter like `abc`
|
||||
used to 400 with `key <full-text-column> not found` (broke the Metrics Explorer summary)."""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=METRIC,
|
||||
labels={"region": "us"},
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
temporality="Unspecified",
|
||||
type_="Gauge",
|
||||
is_monotonic=False,
|
||||
value=30.0,
|
||||
)
|
||||
]
|
||||
)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# bare word and quoted term are both full-text searches; neither may 400.
|
||||
for expr in ("abc", '"abc"'):
|
||||
response = querier.make_scalar_query_request(
|
||||
signoz,
|
||||
token,
|
||||
now,
|
||||
[
|
||||
querier.build_scalar_query(
|
||||
name="A",
|
||||
signal="metrics",
|
||||
aggregations=[querier.build_metrics_aggregation(METRIC, "latest", "sum", "unspecified")],
|
||||
filter_expression=expr,
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"{expr}: {response.text}"
|
||||
# the term matches no series, and metrics emits no key-not-found warning.
|
||||
assert querier.get_scalar_table_data(response.json()) == [], f"{expr}: {response.json()}"
|
||||
assert querier.get_all_warnings(response.json()) == [], f"{expr}: {querier.get_all_warnings(response.json())}"
|
||||
|
||||
Reference in New Issue
Block a user