Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
c6870d74dc test(promql): add upstream promqltest conformance corpus and suite
Freezes an absolute-truth oracle from prometheus@v0.311.3's own
promql/promqltest testdata: a generator command (scripts/promqltestcorpus, go run .)
evaluates upstream's load scripts with the vendored reference engine and
writes 755 cases to a committed corpus; a new integration suite replays
the ingestion and asserts /api/v5/query_range responses against it. The
known-divergences ledger is enforced exactly in both directions and is
empty: the serving path matches the reference engine on every case.

Also folds in the last serving-path fix the corpus surfaced: the v5
output filter dropped every __-prefixed label, mangling labelsets that
legitimately carry one (e.g. __address__); it now strips only known
storage keys (__temporality__, __scope./__resource. prefixes).

The metrics fixture writes registration rows per (series, hour bucket)
with hour-floored timestamps, matching the exporter; the queriermetrics
dormant-metric warning test now uses genuinely dormant data (production-
shaped registration shares an hour bucket with recently-stale data).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 17:10:06 +05:30
91 changed files with 132637 additions and 2037 deletions

View File

@@ -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' ||

View File

@@ -54,6 +54,7 @@ jobs:
- querierscalar
- queriercommon
- rawexportdata
- promqlconformance
- querierauthz
- role
- rootuser

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 ||

View File

@@ -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,
},
});
}

View File

@@ -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,
);
},
);
});

View File

@@ -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}

View File

@@ -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">

View File

@@ -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',

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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
*/

View File

@@ -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', () => {

View File

@@ -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' },
];
/**

View File

@@ -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> = {

View File

@@ -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()));

View File

@@ -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));
});
});

View File

@@ -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);
});
});
});

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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 =>

View File

@@ -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>

View File

@@ -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 = '>' | '<' | '>=' | '<=' | '=' | '!=';

View File

@@ -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: [],

View File

@@ -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 (

View File

@@ -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;
}
}

View File

@@ -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>
);
}

View File

@@ -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]);
}

View File

@@ -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('&&');
});
});

View File

@@ -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');
});
});

View File

@@ -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('');
});
});

View File

@@ -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 {

View File

@@ -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],
);
}

View File

@@ -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],
);
}

View File

@@ -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('');
});
});

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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 ""

View File

@@ -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))
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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())

View File

@@ -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
}

View File

@@ -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}}
)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View 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
}

View 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
)

View 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=

View 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
}

Binary file not shown.

View 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

View File

@@ -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(

File diff suppressed because it is too large Load Diff

View 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": {}
}

View File

@@ -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(

View 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(

View File

@@ -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(

View 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(

View 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(

View 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(

View 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(

View 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(

View 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(

View 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(

View 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]}"

View File

@@ -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}"

View File

@@ -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

View File

@@ -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

View File

@@ -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}},
]

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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())}"