Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
0940db0875 fix(uplot): stop the time-scale trim hiding data on short windows
Assisted-by: Claude Opus 5
2026-08-12 16:24:06 +05:30
72 changed files with 959 additions and 1778 deletions

View File

@@ -58,7 +58,6 @@ jobs:
- querierai
- rawexportdata
- promqlconformance
- promapiconformance
- querierauthz
- role
- rootuser

View File

@@ -10,10 +10,6 @@ jest.mock('providers/Timezone', () => ({
}),
}));
jest.mock('providers/App/App', () => ({
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
}));
const field = (name: string, type = ''): IField => ({
name,
type,

View File

@@ -2,15 +2,13 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { FeatureKeys } from 'constants/features';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { useAppContext } from 'providers/App/App';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -28,10 +26,6 @@ export function useLogsTableColumns({
fontSize,
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
@@ -94,8 +88,7 @@ export function useLogsTableColumns({
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
id: buildCompositeKey(f.name, f.type),
header: f.name,
accessorFn: (log): unknown =>
getLogFieldValue(log, f.name, isBodyJsonEnabled),
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
@@ -122,5 +115,5 @@ export function useLogsTableColumns({
.filter((c): c is TableColumnDef<ILog> => c !== null);
return [stateIndicatorCol, ...fieldCols];
}, [fields, fontSize, formatTimezoneAdjustedTimestamp, isBodyJsonEnabled]);
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -59,7 +59,6 @@ import {
dedupeOptionsByLabel,
getFieldContextPrefix,
getRecentOptions,
isSupportedFunction,
renderRecentDeleteButton,
} from './utils';
@@ -1276,13 +1275,11 @@ function QuerySearch({
}
if (queryContext.isInFunction) {
options = Object.values(QUERY_BUILDER_FUNCTIONS)
.filter((option) => isSupportedFunction(option, dataSource))
.map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
options = Object.values(QUERY_BUILDER_FUNCTIONS).map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
// Add space after selection for functions
const optionsWithSpace = addSpaceToOptions(options);

View File

@@ -1,12 +1,8 @@
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import { DataSource } from 'types/common/queryBuilder';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
getFieldContextPrefix,
getUserExpressionFromCombined,
isSupportedFunction,
} from '../utils';
describe('entityLogsExpression', () => {
@@ -122,19 +118,3 @@ describe('dedupeOptionsByLabel', () => {
expect(dedupeOptionsByLabel([])).toStrictEqual([]);
});
});
describe('isSupportedFunction', () => {
const { HASANY, SEARCH } = QUERY_BUILDER_FUNCTIONS;
it('allows the has family on every signal', () => {
[DataSource.LOGS, DataSource.TRACES, DataSource.METRICS].forEach((signal) => {
expect(isSupportedFunction(HASANY, signal)).toBe(true);
});
});
it('allows search on logs only', () => {
expect(isSupportedFunction(SEARCH, DataSource.LOGS)).toBe(true);
expect(isSupportedFunction(SEARCH, DataSource.TRACES)).toBe(false);
expect(isSupportedFunction(SEARCH, DataSource.METRICS)).toBe(false);
});
});

View File

@@ -1,7 +1,6 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
@@ -16,15 +15,6 @@ import {
RECENTS_SECTION,
} from './constants';
// search() lives in the logs condition builder only; traces and metrics reject it
// as an unsupported operator. Every other function is implemented for all signals.
export function isSupportedFunction(
functionName: string,
signal: SignalType,
): boolean {
return functionName !== QUERY_BUILDER_FUNCTIONS.SEARCH || signal === 'logs';
}
export interface FieldContextPrefixMatch {
context: string;
remainder: string;

View File

@@ -41,7 +41,6 @@ export const QUERY_BUILDER_FUNCTIONS = {
HASANY: 'hasAny',
HASALL: 'hasAll',
HASTOKEN: 'hasToken',
SEARCH: 'search',
};
export function negateOperator(operatorOrFunction: string): string {

View File

@@ -27,6 +27,7 @@ export interface BaseConfigBuilderProps {
panelType: PANEL_TYPES;
minTimeScale?: number;
maxTimeScale?: number;
useExactTimeRange?: boolean;
stepInterval?: number;
isLogScale?: boolean;
yAxisUnit?: string;
@@ -46,6 +47,7 @@ export function buildBaseConfig({
thresholds,
minTimeScale,
maxTimeScale,
useExactTimeRange,
stepInterval,
isLogScale,
yAxisUnit,
@@ -88,6 +90,7 @@ export function buildBaseConfig({
time: true,
min: minTimeScale,
max: maxTimeScale,
useExactTimeRange,
logBase: isLogScale ? 10 : undefined,
distribution: isLogScale
? DistributionType.Logarithmic

View File

@@ -1,55 +0,0 @@
import { ILog } from 'types/api/logs/log';
import { getLogFieldValue } from './flatLogData';
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
describe('getLogFieldValue', () => {
it('resolves a nested body field by dotted key when use_json_body is on', () => {
const log = asLog({ body: { a: { b: { c: 'deep' } } } });
expect(getLogFieldValue(log, 'a.b.c', true)).toBe('deep');
});
it('ignores body when use_json_body is off', () => {
const log = asLog({ body: { a: { b: { c: 'deep' } } } });
expect(getLogFieldValue(log, 'a.b.c', false)).toBeUndefined();
});
it('ignores a stringified body even when use_json_body is on', () => {
const log = asLog({ body: '{"a":{"b":1}}' });
expect(getLogFieldValue(log, 'a.b', true)).toBeUndefined();
});
it('prefers the body value over attributes when the key exists in both (body first)', () => {
const log = asLog({
attributes_string: { 'a.b': 'attr' } as never,
body: { a: { b: 'bodyval' } },
});
expect(getLogFieldValue(log, 'a.b', true)).toBe('bodyval');
});
it('falls back to attributes when the key is not in the body', () => {
const log = asLog({
attributes_string: { 'x.y': 'attr' } as never,
body: { other: 1 },
});
expect(getLogFieldValue(log, 'x.y', true)).toBe('attr');
});
it('preserves falsy body values (0, false, empty string)', () => {
const log = asLog({ body: { n: 0, flag: false, s: '' } });
expect(getLogFieldValue(log, 'n', true)).toBe(0);
expect(getLogFieldValue(log, 'flag', true)).toBe(false);
expect(getLogFieldValue(log, 's', true)).toBe('');
});
it('returns undefined when the body path is missing', () => {
const log = asLog({ body: { x: 1 } });
expect(getLogFieldValue(log, 'nope', true)).toBeUndefined();
});
it('returns undefined when a mid path segment is not an object', () => {
const log = asLog({ body: { a: { b: 'leaf' } } });
expect(getLogFieldValue(log, 'a.b.c', true)).toBeUndefined();
});
});

View File

@@ -1,5 +1,5 @@
import { defaultTo } from 'lodash-es';
import { ILog, ILogBody } from 'types/api/logs/log';
import { ILog } from 'types/api/logs/log';
export function FlatLogData(log: ILog): Record<string, string> {
const flattenLogObject: Record<string, string> = {};
@@ -15,29 +15,3 @@ export function FlatLogData(log: ILog): Record<string, string> {
});
return flattenLogObject;
}
function getBodyFieldValue(body: ILogBody, key: string): unknown {
return key.split('.').reduce<unknown>((acc, segment) => {
if (acc && typeof acc === 'object' && !Array.isArray(acc)) {
return (acc as Record<string, unknown>)[segment];
}
return undefined;
}, body);
}
// Resolve one field for the logs table. A JSON body is checked first (use_json_body
// only), splitting the key on `.`; otherwise fall back to FlatLogData
// (attributes/resources/scope/top-level).
export function getLogFieldValue(
log: ILog,
fieldName: string,
isBodyJsonEnabled: boolean,
): unknown {
if (isBodyJsonEnabled && log.body && typeof log.body === 'object') {
const bodyValue = getBodyFieldValue(log.body, fieldName);
if (bodyValue !== undefined) {
return bodyValue;
}
}
return FlatLogData(log)[fieldName];
}

View File

@@ -34,7 +34,7 @@ describe('normalizeFilterExpression', () => {
);
});
it('lowercases HAS / HASANY / HASALL / HASTOKEN / SEARCH function names', () => {
it('lowercases HAS / HASANY / HASALL / HASTOKEN function names', () => {
expect(normalizeFilterExpression('HAS(tags, "x")')).toBe(
normalizeFilterExpression('has(tags, "x")'),
);
@@ -47,9 +47,6 @@ describe('normalizeFilterExpression', () => {
expect(normalizeFilterExpression('HASTOKEN(msg, "err")')).toBe(
normalizeFilterExpression('hasToken(msg, "err")'),
);
expect(normalizeFilterExpression('SEARCH("err")')).toBe(
normalizeFilterExpression('search("err")'),
);
});
it('lowercases TRUE / FALSE boolean literals', () => {

View File

@@ -42,6 +42,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
logBase = 10,
padMinBy = 0,
padMaxBy = 0.05,
useExactTimeRange = false,
} = this.props;
// Special handling for time scales (X axis)
@@ -58,14 +59,20 @@ export class UPlotScaleBuilder extends ConfigBuilder<
// Align max time to "endTime - 1 minute", rounded down to minute precision
// This matches legacy getXAxisScale behavior and avoids empty space at the right edge
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
if (!useExactTimeRange) {
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
maxTime = unixTimestampSeconds;
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
// Trimming past min inverts the range, which uPlot draws as an empty plot.
if (unixTimestampSeconds > minTime) {
maxTime = unixTimestampSeconds;
}
}
return {
[scaleKey]: {

View File

@@ -79,6 +79,44 @@ describe('UPlotScaleBuilder', () => {
expect(resolvedMax).toBe(expectedMax);
});
it('plots min/max as given when useExactTimeRange is set', () => {
const min = 1_700_000_000;
const max = 1_700_000_630;
const builder = new UPlotScaleBuilder(
createScaleProps({
scaleKey: 'x',
time: true,
min,
max,
useExactTimeRange: true,
}),
);
const config = builder.getConfig();
expect(config.x.range).toStrictEqual([min, max]);
});
it('keeps the requested end when the window is shorter than the trim', () => {
// 23 second window: trimming a minute off the end would put max before min.
const min = 1_786_527_160;
const max = 1_786_527_183;
const builder = new UPlotScaleBuilder(
createScaleProps({
scaleKey: 'x',
time: true,
min,
max,
}),
);
const config = builder.getConfig();
expect(config.x.range).toStrictEqual([min, max]);
});
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
getFallbackMinMaxSpy.mockReturnValue({
fallbackMin: 100,

View File

@@ -97,6 +97,8 @@ export interface ScaleProps {
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
/** Plots a time scale's `min`/`max` as given, skipping the trim below. */
useExactTimeRange?: boolean;
}
export enum DisconnectedValuesMode {

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

File diff suppressed because one or more lines are too long

View File

@@ -24,13 +24,12 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
'('=1
')'=2
'['=3

View File

@@ -1,4 +1,4 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.2
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// noinspection ES6UnusedImports,JSUnusedGlobalSymbols,JSUnusedLocalSymbols
import {
ATN,
@@ -38,13 +38,12 @@ export default class FilterQueryLexer extends Lexer {
public static readonly HAS = 24;
public static readonly HASANY = 25;
public static readonly HASALL = 26;
public static readonly SEARCH = 27;
public static readonly BOOL = 28;
public static readonly NUMBER = 29;
public static readonly QUOTED_TEXT = 30;
public static readonly KEY = 31;
public static readonly WS = 32;
public static readonly FREETEXT = 33;
public static readonly BOOL = 27;
public static readonly NUMBER = 28;
public static readonly QUOTED_TEXT = 29;
public static readonly KEY = 30;
public static readonly WS = 31;
public static readonly FREETEXT = 32;
public static readonly EOF = Token.EOF;
public static readonly channelNames: string[] = [ "DEFAULT_TOKEN_CHANNEL", "HIDDEN" ];
@@ -69,9 +68,8 @@ export default class FilterQueryLexer extends Lexer {
"AND", "OR",
"HASTOKEN",
"HAS", "HASANY",
"HASALL", "SEARCH",
"BOOL", "NUMBER",
"QUOTED_TEXT",
"HASALL", "BOOL",
"NUMBER", "QUOTED_TEXT",
"KEY", "WS",
"FREETEXT" ];
public static readonly modeNames: string[] = [ "DEFAULT_MODE", ];
@@ -80,8 +78,8 @@ export default class FilterQueryLexer extends Lexer {
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS", "REGEXP",
"CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY", "HASALL",
"SEARCH", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS",
"OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
"BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS", "OLD_JSON_BRACKS",
"KEY", "WS", "DIGIT", "FREETEXT",
];
@@ -102,122 +100,119 @@ export default class FilterQueryLexer extends Lexer {
public get modeNames(): string[] { return FilterQueryLexer.modeNames; }
public static readonly _serializedATN: number[] = [4,0,33,329,6,-1,2,0,
public static readonly _serializedATN: number[] = [4,0,32,320,6,-1,2,0,
7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,7,2,8,7,8,2,9,
7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14,2,15,7,15,2,16,7,
16,2,17,7,17,2,18,7,18,2,19,7,19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,
2,24,7,24,2,25,7,25,2,26,7,26,2,27,7,27,2,28,7,28,2,29,7,29,2,30,7,30,2,
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,2,37,7,37,1,0,
1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,91,8,5,1,6,1,6,1,6,
1,7,1,7,1,7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,
1,12,1,12,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,
14,1,14,1,15,1,15,1,15,1,15,1,15,1,15,3,15,134,8,15,1,16,1,16,1,16,1,16,
1,16,1,16,1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,151,8,17,1,
18,1,18,1,18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,
24,1,24,1,24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,3,27,210,
8,27,1,28,1,28,1,29,3,29,215,8,29,1,29,4,29,218,8,29,11,29,12,29,219,1,
29,1,29,5,29,224,8,29,10,29,12,29,227,9,29,3,29,229,8,29,1,29,1,29,3,29,
233,8,29,1,29,4,29,236,8,29,11,29,12,29,237,3,29,240,8,29,1,29,3,29,243,
8,29,1,29,1,29,4,29,247,8,29,11,29,12,29,248,1,29,1,29,3,29,253,8,29,1,
29,4,29,256,8,29,11,29,12,29,257,3,29,260,8,29,3,29,262,8,29,1,30,1,30,
1,30,1,30,5,30,268,8,30,10,30,12,30,271,9,30,1,30,1,30,1,30,1,30,1,30,5,
30,278,8,30,10,30,12,30,281,9,30,1,30,3,30,284,8,30,1,31,1,31,5,31,288,
8,31,10,31,12,31,291,9,31,1,32,1,32,1,32,1,33,1,33,1,33,1,33,1,34,1,34,
1,34,1,34,1,34,1,34,1,34,4,34,307,8,34,11,34,12,34,308,5,34,311,8,34,10,
34,12,34,314,9,34,1,35,4,35,317,8,35,11,35,12,35,318,1,35,1,35,1,36,1,36,
1,37,4,37,326,8,37,11,37,12,37,327,0,0,38,1,1,3,2,5,3,7,4,9,5,11,6,13,7,
15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,16,33,17,35,18,37,19,39,
20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,28,57,0,59,29,61,30,63,
0,65,0,67,0,69,31,71,32,73,0,75,33,1,0,29,2,0,76,76,108,108,2,0,73,73,105,
105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,98,2,0,84,84,116,116,
2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,120,2,0,83,83,115,115,
2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,112,2,0,67,67,99,99,2,
0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,2,0,72,72,104,104,2,0,
89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,2,0,43,43,45,45,2,0,34,
34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,123,7,0,35,36,45,45,47,
58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,32,1,0,48,57,8,0,9,10,13,
13,32,34,39,41,44,44,60,62,91,91,93,93,353,0,1,1,0,0,0,0,3,1,0,0,0,0,5,
1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,
0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,
0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37,1,0,0,0,
0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,
0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,55,1,0,0,0,0,59,1,0,0,0,0,61,1,0,0,0,
0,69,1,0,0,0,0,71,1,0,0,0,0,75,1,0,0,0,1,77,1,0,0,0,3,79,1,0,0,0,5,81,1,
0,0,0,7,83,1,0,0,0,9,85,1,0,0,0,11,90,1,0,0,0,13,92,1,0,0,0,15,95,1,0,0,
0,17,98,1,0,0,0,19,100,1,0,0,0,21,103,1,0,0,0,23,105,1,0,0,0,25,108,1,0,
0,0,27,113,1,0,0,0,29,119,1,0,0,0,31,127,1,0,0,0,33,135,1,0,0,0,35,142,
1,0,0,0,37,152,1,0,0,0,39,155,1,0,0,0,41,159,1,0,0,0,43,163,1,0,0,0,45,
166,1,0,0,0,47,175,1,0,0,0,49,179,1,0,0,0,51,186,1,0,0,0,53,193,1,0,0,0,
55,209,1,0,0,0,57,211,1,0,0,0,59,261,1,0,0,0,61,283,1,0,0,0,63,285,1,0,
0,0,65,292,1,0,0,0,67,295,1,0,0,0,69,299,1,0,0,0,71,316,1,0,0,0,73,322,
1,0,0,0,75,325,1,0,0,0,77,78,5,40,0,0,78,2,1,0,0,0,79,80,5,41,0,0,80,4,
1,0,0,0,81,82,5,91,0,0,82,6,1,0,0,0,83,84,5,93,0,0,84,8,1,0,0,0,85,86,5,
44,0,0,86,10,1,0,0,0,87,91,5,61,0,0,88,89,5,61,0,0,89,91,5,61,0,0,90,87,
1,0,0,0,90,88,1,0,0,0,91,12,1,0,0,0,92,93,5,33,0,0,93,94,5,61,0,0,94,14,
1,0,0,0,95,96,5,60,0,0,96,97,5,62,0,0,97,16,1,0,0,0,98,99,5,60,0,0,99,18,
1,0,0,0,100,101,5,60,0,0,101,102,5,61,0,0,102,20,1,0,0,0,103,104,5,62,0,
0,104,22,1,0,0,0,105,106,5,62,0,0,106,107,5,61,0,0,107,24,1,0,0,0,108,109,
7,0,0,0,109,110,7,1,0,0,110,111,7,2,0,0,111,112,7,3,0,0,112,26,1,0,0,0,
113,114,7,1,0,0,114,115,7,0,0,0,115,116,7,1,0,0,116,117,7,2,0,0,117,118,
7,3,0,0,118,28,1,0,0,0,119,120,7,4,0,0,120,121,7,3,0,0,121,122,7,5,0,0,
122,123,7,6,0,0,123,124,7,3,0,0,124,125,7,3,0,0,125,126,7,7,0,0,126,30,
1,0,0,0,127,128,7,3,0,0,128,129,7,8,0,0,129,130,7,1,0,0,130,131,7,9,0,0,
131,133,7,5,0,0,132,134,7,9,0,0,133,132,1,0,0,0,133,134,1,0,0,0,134,32,
1,0,0,0,135,136,7,10,0,0,136,137,7,3,0,0,137,138,7,11,0,0,138,139,7,3,0,
0,139,140,7,8,0,0,140,141,7,12,0,0,141,34,1,0,0,0,142,143,7,13,0,0,143,
144,7,14,0,0,144,145,7,7,0,0,145,146,7,5,0,0,146,147,7,15,0,0,147,148,7,
1,0,0,148,150,7,7,0,0,149,151,7,9,0,0,150,149,1,0,0,0,150,151,1,0,0,0,151,
36,1,0,0,0,152,153,7,1,0,0,153,154,7,7,0,0,154,38,1,0,0,0,155,156,7,7,0,
0,156,157,7,14,0,0,157,158,7,5,0,0,158,40,1,0,0,0,159,160,7,15,0,0,160,
161,7,7,0,0,161,162,7,16,0,0,162,42,1,0,0,0,163,164,7,14,0,0,164,165,7,
10,0,0,165,44,1,0,0,0,166,167,7,17,0,0,167,168,7,15,0,0,168,169,7,9,0,0,
169,170,7,5,0,0,170,171,7,14,0,0,171,172,7,2,0,0,172,173,7,3,0,0,173,174,
7,7,0,0,174,46,1,0,0,0,175,176,7,17,0,0,176,177,7,15,0,0,177,178,7,9,0,
0,178,48,1,0,0,0,179,180,7,17,0,0,180,181,7,15,0,0,181,182,7,9,0,0,182,
183,7,15,0,0,183,184,7,7,0,0,184,185,7,18,0,0,185,50,1,0,0,0,186,187,7,
17,0,0,187,188,7,15,0,0,188,189,7,9,0,0,189,190,7,15,0,0,190,191,7,0,0,
0,191,192,7,0,0,0,192,52,1,0,0,0,193,194,7,9,0,0,194,195,7,3,0,0,195,196,
7,15,0,0,196,197,7,10,0,0,197,198,7,13,0,0,198,199,7,17,0,0,199,54,1,0,
0,0,200,201,7,5,0,0,201,202,7,10,0,0,202,203,7,19,0,0,203,210,7,3,0,0,204,
205,7,20,0,0,205,206,7,15,0,0,206,207,7,0,0,0,207,208,7,9,0,0,208,210,7,
3,0,0,209,200,1,0,0,0,209,204,1,0,0,0,210,56,1,0,0,0,211,212,7,21,0,0,212,
58,1,0,0,0,213,215,3,57,28,0,214,213,1,0,0,0,214,215,1,0,0,0,215,217,1,
0,0,0,216,218,3,73,36,0,217,216,1,0,0,0,218,219,1,0,0,0,219,217,1,0,0,0,
219,220,1,0,0,0,220,228,1,0,0,0,221,225,5,46,0,0,222,224,3,73,36,0,223,
222,1,0,0,0,224,227,1,0,0,0,225,223,1,0,0,0,225,226,1,0,0,0,226,229,1,0,
0,0,227,225,1,0,0,0,228,221,1,0,0,0,228,229,1,0,0,0,229,239,1,0,0,0,230,
232,7,3,0,0,231,233,3,57,28,0,232,231,1,0,0,0,232,233,1,0,0,0,233,235,1,
0,0,0,234,236,3,73,36,0,235,234,1,0,0,0,236,237,1,0,0,0,237,235,1,0,0,0,
237,238,1,0,0,0,238,240,1,0,0,0,239,230,1,0,0,0,239,240,1,0,0,0,240,262,
1,0,0,0,241,243,3,57,28,0,242,241,1,0,0,0,242,243,1,0,0,0,243,244,1,0,0,
0,244,246,5,46,0,0,245,247,3,73,36,0,246,245,1,0,0,0,247,248,1,0,0,0,248,
246,1,0,0,0,248,249,1,0,0,0,249,259,1,0,0,0,250,252,7,3,0,0,251,253,3,57,
28,0,252,251,1,0,0,0,252,253,1,0,0,0,253,255,1,0,0,0,254,256,3,73,36,0,
255,254,1,0,0,0,256,257,1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258,260,
1,0,0,0,259,250,1,0,0,0,259,260,1,0,0,0,260,262,1,0,0,0,261,214,1,0,0,0,
261,242,1,0,0,0,262,60,1,0,0,0,263,269,5,34,0,0,264,268,8,22,0,0,265,266,
5,92,0,0,266,268,9,0,0,0,267,264,1,0,0,0,267,265,1,0,0,0,268,271,1,0,0,
0,269,267,1,0,0,0,269,270,1,0,0,0,270,272,1,0,0,0,271,269,1,0,0,0,272,284,
5,34,0,0,273,279,5,39,0,0,274,278,8,23,0,0,275,276,5,92,0,0,276,278,9,0,
0,0,277,274,1,0,0,0,277,275,1,0,0,0,278,281,1,0,0,0,279,277,1,0,0,0,279,
280,1,0,0,0,280,282,1,0,0,0,281,279,1,0,0,0,282,284,5,39,0,0,283,263,1,
0,0,0,283,273,1,0,0,0,284,62,1,0,0,0,285,289,7,24,0,0,286,288,7,25,0,0,
287,286,1,0,0,0,288,291,1,0,0,0,289,287,1,0,0,0,289,290,1,0,0,0,290,64,
1,0,0,0,291,289,1,0,0,0,292,293,5,91,0,0,293,294,5,93,0,0,294,66,1,0,0,
0,295,296,5,91,0,0,296,297,5,42,0,0,297,298,5,93,0,0,298,68,1,0,0,0,299,
312,3,63,31,0,300,301,5,46,0,0,301,311,3,63,31,0,302,311,3,65,32,0,303,
311,3,67,33,0,304,306,5,46,0,0,305,307,3,73,36,0,306,305,1,0,0,0,307,308,
1,0,0,0,308,306,1,0,0,0,308,309,1,0,0,0,309,311,1,0,0,0,310,300,1,0,0,0,
310,302,1,0,0,0,310,303,1,0,0,0,310,304,1,0,0,0,311,314,1,0,0,0,312,310,
1,0,0,0,312,313,1,0,0,0,313,70,1,0,0,0,314,312,1,0,0,0,315,317,7,26,0,0,
316,315,1,0,0,0,317,318,1,0,0,0,318,316,1,0,0,0,318,319,1,0,0,0,319,320,
1,0,0,0,320,321,6,35,0,0,321,72,1,0,0,0,322,323,7,27,0,0,323,74,1,0,0,0,
324,326,8,28,0,0,325,324,1,0,0,0,326,327,1,0,0,0,327,325,1,0,0,0,327,328,
1,0,0,0,328,76,1,0,0,0,29,0,90,133,150,209,214,219,225,228,232,237,239,
242,248,252,257,259,261,267,269,277,279,283,289,308,310,312,318,327,1,6,
0,0];
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,1,0,1,0,1,1,1,
1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,89,8,5,1,6,1,6,1,6,1,7,1,7,1,
7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,1,12,1,12,
1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,
15,1,15,1,15,1,15,1,15,1,15,3,15,132,8,15,1,16,1,16,1,16,1,16,1,16,1,16,
1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,149,8,17,1,18,1,18,1,
18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,1,22,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,24,1,24,1,
24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,3,26,201,8,26,1,27,1,27,1,28,3,28,206,8,28,1,28,4,28,
209,8,28,11,28,12,28,210,1,28,1,28,5,28,215,8,28,10,28,12,28,218,9,28,3,
28,220,8,28,1,28,1,28,3,28,224,8,28,1,28,4,28,227,8,28,11,28,12,28,228,
3,28,231,8,28,1,28,3,28,234,8,28,1,28,1,28,4,28,238,8,28,11,28,12,28,239,
1,28,1,28,3,28,244,8,28,1,28,4,28,247,8,28,11,28,12,28,248,3,28,251,8,28,
3,28,253,8,28,1,29,1,29,1,29,1,29,5,29,259,8,29,10,29,12,29,262,9,29,1,
29,1,29,1,29,1,29,1,29,5,29,269,8,29,10,29,12,29,272,9,29,1,29,3,29,275,
8,29,1,30,1,30,5,30,279,8,30,10,30,12,30,282,9,30,1,31,1,31,1,31,1,32,1,
32,1,32,1,32,1,33,1,33,1,33,1,33,1,33,1,33,1,33,4,33,298,8,33,11,33,12,
33,299,5,33,302,8,33,10,33,12,33,305,9,33,1,34,4,34,308,8,34,11,34,12,34,
309,1,34,1,34,1,35,1,35,1,36,4,36,317,8,36,11,36,12,36,318,0,0,37,1,1,3,
2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,
16,33,17,35,18,37,19,39,20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,
0,57,28,59,29,61,0,63,0,65,0,67,30,69,31,71,0,73,32,1,0,29,2,0,76,76,108,
108,2,0,73,73,105,105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,
98,2,0,84,84,116,116,2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,
120,2,0,83,83,115,115,2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,
112,2,0,67,67,99,99,2,0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,
2,0,72,72,104,104,2,0,89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,
2,0,43,43,45,45,2,0,34,34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,
123,7,0,35,36,45,45,47,58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,
32,1,0,48,57,8,0,9,10,13,13,32,34,39,41,44,44,60,62,91,91,93,93,344,0,1,
1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,
13,1,0,0,0,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,
0,0,0,25,1,0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,
35,1,0,0,0,0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,
0,0,0,47,1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,57,1,0,0,0,0,
59,1,0,0,0,0,67,1,0,0,0,0,69,1,0,0,0,0,73,1,0,0,0,1,75,1,0,0,0,3,77,1,0,
0,0,5,79,1,0,0,0,7,81,1,0,0,0,9,83,1,0,0,0,11,88,1,0,0,0,13,90,1,0,0,0,
15,93,1,0,0,0,17,96,1,0,0,0,19,98,1,0,0,0,21,101,1,0,0,0,23,103,1,0,0,0,
25,106,1,0,0,0,27,111,1,0,0,0,29,117,1,0,0,0,31,125,1,0,0,0,33,133,1,0,
0,0,35,140,1,0,0,0,37,150,1,0,0,0,39,153,1,0,0,0,41,157,1,0,0,0,43,161,
1,0,0,0,45,164,1,0,0,0,47,173,1,0,0,0,49,177,1,0,0,0,51,184,1,0,0,0,53,
200,1,0,0,0,55,202,1,0,0,0,57,252,1,0,0,0,59,274,1,0,0,0,61,276,1,0,0,0,
63,283,1,0,0,0,65,286,1,0,0,0,67,290,1,0,0,0,69,307,1,0,0,0,71,313,1,0,
0,0,73,316,1,0,0,0,75,76,5,40,0,0,76,2,1,0,0,0,77,78,5,41,0,0,78,4,1,0,
0,0,79,80,5,91,0,0,80,6,1,0,0,0,81,82,5,93,0,0,82,8,1,0,0,0,83,84,5,44,
0,0,84,10,1,0,0,0,85,89,5,61,0,0,86,87,5,61,0,0,87,89,5,61,0,0,88,85,1,
0,0,0,88,86,1,0,0,0,89,12,1,0,0,0,90,91,5,33,0,0,91,92,5,61,0,0,92,14,1,
0,0,0,93,94,5,60,0,0,94,95,5,62,0,0,95,16,1,0,0,0,96,97,5,60,0,0,97,18,
1,0,0,0,98,99,5,60,0,0,99,100,5,61,0,0,100,20,1,0,0,0,101,102,5,62,0,0,
102,22,1,0,0,0,103,104,5,62,0,0,104,105,5,61,0,0,105,24,1,0,0,0,106,107,
7,0,0,0,107,108,7,1,0,0,108,109,7,2,0,0,109,110,7,3,0,0,110,26,1,0,0,0,
111,112,7,1,0,0,112,113,7,0,0,0,113,114,7,1,0,0,114,115,7,2,0,0,115,116,
7,3,0,0,116,28,1,0,0,0,117,118,7,4,0,0,118,119,7,3,0,0,119,120,7,5,0,0,
120,121,7,6,0,0,121,122,7,3,0,0,122,123,7,3,0,0,123,124,7,7,0,0,124,30,
1,0,0,0,125,126,7,3,0,0,126,127,7,8,0,0,127,128,7,1,0,0,128,129,7,9,0,0,
129,131,7,5,0,0,130,132,7,9,0,0,131,130,1,0,0,0,131,132,1,0,0,0,132,32,
1,0,0,0,133,134,7,10,0,0,134,135,7,3,0,0,135,136,7,11,0,0,136,137,7,3,0,
0,137,138,7,8,0,0,138,139,7,12,0,0,139,34,1,0,0,0,140,141,7,13,0,0,141,
142,7,14,0,0,142,143,7,7,0,0,143,144,7,5,0,0,144,145,7,15,0,0,145,146,7,
1,0,0,146,148,7,7,0,0,147,149,7,9,0,0,148,147,1,0,0,0,148,149,1,0,0,0,149,
36,1,0,0,0,150,151,7,1,0,0,151,152,7,7,0,0,152,38,1,0,0,0,153,154,7,7,0,
0,154,155,7,14,0,0,155,156,7,5,0,0,156,40,1,0,0,0,157,158,7,15,0,0,158,
159,7,7,0,0,159,160,7,16,0,0,160,42,1,0,0,0,161,162,7,14,0,0,162,163,7,
10,0,0,163,44,1,0,0,0,164,165,7,17,0,0,165,166,7,15,0,0,166,167,7,9,0,0,
167,168,7,5,0,0,168,169,7,14,0,0,169,170,7,2,0,0,170,171,7,3,0,0,171,172,
7,7,0,0,172,46,1,0,0,0,173,174,7,17,0,0,174,175,7,15,0,0,175,176,7,9,0,
0,176,48,1,0,0,0,177,178,7,17,0,0,178,179,7,15,0,0,179,180,7,9,0,0,180,
181,7,15,0,0,181,182,7,7,0,0,182,183,7,18,0,0,183,50,1,0,0,0,184,185,7,
17,0,0,185,186,7,15,0,0,186,187,7,9,0,0,187,188,7,15,0,0,188,189,7,0,0,
0,189,190,7,0,0,0,190,52,1,0,0,0,191,192,7,5,0,0,192,193,7,10,0,0,193,194,
7,19,0,0,194,201,7,3,0,0,195,196,7,20,0,0,196,197,7,15,0,0,197,198,7,0,
0,0,198,199,7,9,0,0,199,201,7,3,0,0,200,191,1,0,0,0,200,195,1,0,0,0,201,
54,1,0,0,0,202,203,7,21,0,0,203,56,1,0,0,0,204,206,3,55,27,0,205,204,1,
0,0,0,205,206,1,0,0,0,206,208,1,0,0,0,207,209,3,71,35,0,208,207,1,0,0,0,
209,210,1,0,0,0,210,208,1,0,0,0,210,211,1,0,0,0,211,219,1,0,0,0,212,216,
5,46,0,0,213,215,3,71,35,0,214,213,1,0,0,0,215,218,1,0,0,0,216,214,1,0,
0,0,216,217,1,0,0,0,217,220,1,0,0,0,218,216,1,0,0,0,219,212,1,0,0,0,219,
220,1,0,0,0,220,230,1,0,0,0,221,223,7,3,0,0,222,224,3,55,27,0,223,222,1,
0,0,0,223,224,1,0,0,0,224,226,1,0,0,0,225,227,3,71,35,0,226,225,1,0,0,0,
227,228,1,0,0,0,228,226,1,0,0,0,228,229,1,0,0,0,229,231,1,0,0,0,230,221,
1,0,0,0,230,231,1,0,0,0,231,253,1,0,0,0,232,234,3,55,27,0,233,232,1,0,0,
0,233,234,1,0,0,0,234,235,1,0,0,0,235,237,5,46,0,0,236,238,3,71,35,0,237,
236,1,0,0,0,238,239,1,0,0,0,239,237,1,0,0,0,239,240,1,0,0,0,240,250,1,0,
0,0,241,243,7,3,0,0,242,244,3,55,27,0,243,242,1,0,0,0,243,244,1,0,0,0,244,
246,1,0,0,0,245,247,3,71,35,0,246,245,1,0,0,0,247,248,1,0,0,0,248,246,1,
0,0,0,248,249,1,0,0,0,249,251,1,0,0,0,250,241,1,0,0,0,250,251,1,0,0,0,251,
253,1,0,0,0,252,205,1,0,0,0,252,233,1,0,0,0,253,58,1,0,0,0,254,260,5,34,
0,0,255,259,8,22,0,0,256,257,5,92,0,0,257,259,9,0,0,0,258,255,1,0,0,0,258,
256,1,0,0,0,259,262,1,0,0,0,260,258,1,0,0,0,260,261,1,0,0,0,261,263,1,0,
0,0,262,260,1,0,0,0,263,275,5,34,0,0,264,270,5,39,0,0,265,269,8,23,0,0,
266,267,5,92,0,0,267,269,9,0,0,0,268,265,1,0,0,0,268,266,1,0,0,0,269,272,
1,0,0,0,270,268,1,0,0,0,270,271,1,0,0,0,271,273,1,0,0,0,272,270,1,0,0,0,
273,275,5,39,0,0,274,254,1,0,0,0,274,264,1,0,0,0,275,60,1,0,0,0,276,280,
7,24,0,0,277,279,7,25,0,0,278,277,1,0,0,0,279,282,1,0,0,0,280,278,1,0,0,
0,280,281,1,0,0,0,281,62,1,0,0,0,282,280,1,0,0,0,283,284,5,91,0,0,284,285,
5,93,0,0,285,64,1,0,0,0,286,287,5,91,0,0,287,288,5,42,0,0,288,289,5,93,
0,0,289,66,1,0,0,0,290,303,3,61,30,0,291,292,5,46,0,0,292,302,3,61,30,0,
293,302,3,63,31,0,294,302,3,65,32,0,295,297,5,46,0,0,296,298,3,71,35,0,
297,296,1,0,0,0,298,299,1,0,0,0,299,297,1,0,0,0,299,300,1,0,0,0,300,302,
1,0,0,0,301,291,1,0,0,0,301,293,1,0,0,0,301,294,1,0,0,0,301,295,1,0,0,0,
302,305,1,0,0,0,303,301,1,0,0,0,303,304,1,0,0,0,304,68,1,0,0,0,305,303,
1,0,0,0,306,308,7,26,0,0,307,306,1,0,0,0,308,309,1,0,0,0,309,307,1,0,0,
0,309,310,1,0,0,0,310,311,1,0,0,0,311,312,6,34,0,0,312,70,1,0,0,0,313,314,
7,27,0,0,314,72,1,0,0,0,315,317,8,28,0,0,316,315,1,0,0,0,317,318,1,0,0,
0,318,316,1,0,0,0,318,319,1,0,0,0,319,74,1,0,0,0,29,0,88,131,148,200,205,
210,216,219,223,228,230,233,239,243,248,250,252,258,260,268,270,274,280,
299,301,303,309,318,1,6,0,0];
private static __ATN: ATN;
public static get _ATN(): ATN {

View File

@@ -1,26 +1,25 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.2
// Generated from FilterQuery.g4 by ANTLR 4.13.1
import {ParseTreeListener} from "antlr4";
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { SearchCallContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
/**
@@ -148,16 +147,6 @@ export default class FilterQueryListener extends ParseTreeListener {
* @param ctx the parse tree
*/
exitFunctionCall?: (ctx: FunctionCallContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
*/
enterSearchCall?: (ctx: SearchCallContext) => void;
/**
* Exit a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
*/
exitSearchCall?: (ctx: SearchCallContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,25 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.2
// Generated from FilterQuery.g4 by ANTLR 4.13.1
import {ParseTreeVisitor} from 'antlr4';
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { SearchCallContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
/**
@@ -103,12 +102,6 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
* @return the visitor result
*/
visitFunctionCall?: (ctx: FunctionCallContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
* @return the visitor result
*/
visitSearchCall?: (ctx: SearchCallContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

View File

@@ -380,19 +380,6 @@ describe('extractQueryPairs', () => {
consoleSpy.mockRestore();
});
it('does not turn a search() term into a pair', () => {
// The bare form lexes as a KEY; left in it becomes a phantom filter item.
expect(extractQueryPairs("search('err')")).toStrictEqual([]);
expect(extractQueryPairs('search(err)')).toStrictEqual([]);
expect(extractQueryPairs('search(')).toStrictEqual([]);
expect(
extractQueryPairs("search(err) AND service.name = 'api'").map(
(pair) => pair.key,
),
).toStrictEqual(['service.name']);
});
it('should treat lowercase exists as non-value operator', () => {
const input = 'body exists service.name contains "test"';
const result = extractQueryPairs(input);
@@ -834,17 +821,3 @@ describe('getQueryContextAtCursor - partial operator', () => {
expect(ctx.operatorToken).toBe('k');
});
});
describe('getQueryContextAtCursor - function context', () => {
// Each function keyword gets its own lexer token, and every one has to be
// registered as a function token for the editor to offer the function list.
it.each(['has', 'hasAny', 'hasAll', 'hasToken', 'search'])(
'resolves %s to function context',
(functionName) => {
const ctx = getQueryContextAtCursor(functionName, functionName.length);
expect(ctx.isInFunction).toBe(true);
expect(ctx.isInKey).toBe(false);
},
);
});

View File

@@ -1279,41 +1279,6 @@ export function getQueryContextAtCursor(
}
}
// The grammar skips whitespace outright, so hidden-channel tokens do not reach the
// stream today -- but the rest of this file guards against them, so keep the
// assumption in one place rather than spread across callers.
function nextVisibleIndex(tokens: IToken[], start: number): number {
let index = start;
while (index < tokens.length && tokens[index].channel !== 0) {
index += 1;
}
return index;
}
// Returns the token index just past the parenthesised argument list at
// argumentStart, or argumentStart when none follows (a call the user is still
// typing). An unclosed list consumes the remainder.
function indexPastArguments(tokens: IToken[], argumentStart: number): number {
let index = nextVisibleIndex(tokens, argumentStart);
if (index >= tokens.length || tokens[index].type !== FilterQueryLexer.LPAREN) {
return argumentStart;
}
let depth = 0;
for (; index < tokens.length; index++) {
if (tokens[index].type === FilterQueryLexer.LPAREN) {
depth += 1;
} else if (tokens[index].type === FilterQueryLexer.RPAREN) {
depth -= 1;
if (depth === 0) {
return index + 1;
}
}
}
return index;
}
/**
* Extracts all key-operator-value triplets from a query string
* This is useful for getting value suggestions based on the current key and operator
@@ -1359,14 +1324,6 @@ export function extractQueryPairs(query: string): IQueryPair[] {
continue;
}
// A search() term is free text, not a key: the bare form search(x) lexes
// as one, and left in it surfaces as a phantom filter item -- the log
// detail drawer rebuilds its filters from these pairs.
if (token.type === FilterQueryLexer.SEARCH) {
iterator = indexPastArguments(allTokens, iterator);
continue;
}
// If token is a KEY, start a new pair
if (
token.type === FilterQueryLexer.KEY &&

View File

@@ -77,7 +77,6 @@ export function isFunctionToken(tokenType: number): boolean {
FilterQueryLexer.HASANY,
FilterQueryLexer.HASALL,
FilterQueryLexer.HASTOKEN,
FilterQueryLexer.SEARCH,
].includes(tokenType);
}

View File

@@ -1,9 +0,0 @@
package prometheus
import "net/http"
type Handler interface {
Query(http.ResponseWriter, *http.Request)
QueryRange(http.ResponseWriter, *http.Request)
}

View File

@@ -1,259 +0,0 @@
// Package promapi serves the Prometheus HTTP query API over a
// prometheus.Prometheus provider: /query and /query_range in the shape of
// Prometheus' /api/v1 endpoints (https://prometheus.io/docs/prometheus/latest/querying/api/),
// intended to be mounted under a distinguishing prefix (/prometheus/api/v1)
// so PromQL-only endpoints are separate from the SigNoz query APIs. The
// request and response contracts follow Prometheus: form-encoded GET/POST
// params, {"status":"success","data":{resultType,result}} on success and
// {"status":"error","errorType","error"} with Prometheus' status codes on
// failure — so Prometheus-compatible clients can point at the prefix.
package promapi
import (
"context"
"encoding/json"
"log/slog"
"math"
"net/http"
"strconv"
"time"
promModel "github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/stats"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
)
type handler struct {
logger *slog.Logger
prom prometheus.Prometheus
}
func NewHandler(logger *slog.Logger, prom prometheus.Prometheus) prometheus.Handler {
return &handler{logger: logger, prom: prom}
}
type errorType string
const (
errBadData errorType = "bad_data"
errExec errorType = "execution"
errCanceled errorType = "canceled"
errTimeout errorType = "timeout"
errInternal errorType = "internal"
)
type queryData struct {
ResultType parser.ValueType `json:"resultType"`
Result parser.Value `json:"result"`
Stats stats.QueryStats `json:"stats,omitempty"`
}
type response struct {
Status string `json:"status"`
Data *queryData `json:"data,omitempty"`
ErrorType errorType `json:"errorType,omitempty"`
Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Infos []string `json:"infos,omitempty"`
}
// QueryRange evaluates an expression over a grid: query, start, end, step,
// and optional timeout/stats params, all in Prometheus' formats.
func (h *handler) QueryRange(w http.ResponseWriter, r *http.Request) {
start, err := parseTime(r.FormValue("start"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
end, err := parseTime(r.FormValue("end"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
if end.Before(start) {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "end timestamp must not be before start time"))
return
}
step, err := parseDuration(r.FormValue("step"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
if step <= 0 {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "zero or negative query resolution step widths are not accepted. Try a positive integer"))
return
}
// The engine materializes every point of every series; an unbounded
// grid is an unbounded allocation. 11,000 points covers 60s resolution
// for a week or 1h resolution for a year.
if end.Sub(start)/step > 11000 {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "exceeded maximum resolution of 11,000 points per timeseries. Try decreasing the query resolution (?step=XX)"))
return
}
ctx, cancel, err := h.contextWithTimeout(r)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
defer cancel()
if h.tryRangeExecutor(ctx, w, r, start, end, step) {
return
}
qry, err := h.prom.Engine().NewRangeQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), start, end, step)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
// tryRangeExecutor serves the query the way a RangeExecutor provider is
// designed to serve: evaluated inside the datastore when the shape allows.
// It reports whether the response was written.
func (h *handler) tryRangeExecutor(ctx context.Context, w http.ResponseWriter, r *http.Request, start, end time.Time, step time.Duration) bool {
re, ok := h.prom.(prometheus.RangeExecutor)
if !ok {
return false
}
matrix, served, err := re.TryExecuteRange(ctx, r.FormValue("query"), start, end, step)
if err != nil {
h.respondError(ctx, w, errExec, err)
return true
}
if !served {
return false
}
h.respond(ctx, w, &queryData{ResultType: matrix.Type(), Result: matrix}, nil, nil)
return true
}
// Query evaluates an expression at a single instant: query and optional
// time/timeout/stats params. A missing time evaluates at the server's now,
// as in Prometheus.
func (h *handler) Query(w http.ResponseWriter, r *http.Request) {
ts := time.Now()
if t := r.FormValue("time"); t != "" {
var err error
ts, err = parseTime(t)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
}
ctx, cancel, err := h.contextWithTimeout(r)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
defer cancel()
qry, err := h.prom.Engine().NewInstantQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), ts)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
func (h *handler) exec(ctx context.Context, w http.ResponseWriter, r *http.Request, qry promql.Query) {
defer qry.Close()
res := qry.Exec(ctx)
if res.Err != nil {
h.logger.ErrorContext(ctx, "error evaluating promql query", errors.Attr(res.Err))
switch res.Err.(type) {
case promql.ErrQueryCanceled:
h.respondError(ctx, w, errCanceled, res.Err)
case promql.ErrQueryTimeout:
h.respondError(ctx, w, errTimeout, res.Err)
case promql.ErrStorage:
h.respondError(ctx, w, errInternal, res.Err)
default:
h.respondError(ctx, w, errExec, res.Err)
}
return
}
data := &queryData{ResultType: res.Value.Type(), Result: res.Value}
if r.FormValue("stats") != "" {
data.Stats = stats.NewQueryStats(qry.Stats())
}
warnings, infos := res.Warnings.AsStrings(r.FormValue("query"), 10, 10)
h.respond(ctx, w, data, warnings, infos)
}
func (h *handler) contextWithTimeout(r *http.Request) (context.Context, context.CancelFunc, error) {
ctx := r.Context()
if to := r.FormValue("timeout"); to != "" {
timeout, err := parseDuration(to)
if err != nil {
return nil, nil, err
}
ctx, cancel := context.WithTimeout(ctx, timeout)
return ctx, cancel, nil
}
ctx, cancel := context.WithCancel(ctx)
return ctx, cancel, nil
}
func (h *handler) respond(ctx context.Context, w http.ResponseWriter, data *queryData, warnings, infos []string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(&response{Status: "success", Data: data, Warnings: warnings, Infos: infos}); err != nil {
h.logger.ErrorContext(ctx, "error writing prometheus api response", errors.Attr(err))
}
}
// respondError follows Prometheus' status-code mapping: bad_data 400,
// execution 422, canceled/timeout 503, internal 500.
func (h *handler) respondError(ctx context.Context, w http.ResponseWriter, typ errorType, err error) {
code := http.StatusInternalServerError
switch typ {
case errBadData:
code = http.StatusBadRequest
case errExec:
code = http.StatusUnprocessableEntity
case errCanceled, errTimeout:
code = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if encErr := json.NewEncoder(w).Encode(&response{Status: "error", ErrorType: typ, Error: err.Error()}); encErr != nil {
h.logger.ErrorContext(ctx, "error writing prometheus api error response", errors.Attr(encErr))
}
}
// parseTime accepts Prometheus' time formats: float unix seconds or RFC3339.
func parseTime(s string) (time.Time, error) {
if t, err := strconv.ParseFloat(s, 64); err == nil {
sec, ns := math.Modf(t)
return time.Unix(int64(sec), int64(ns*float64(time.Second))), nil
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Time{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid timestamp", s)
}
// parseDuration accepts Prometheus' duration formats: float seconds or a
// duration string like 5m.
func parseDuration(s string) (time.Duration, error) {
if d, err := strconv.ParseFloat(s, 64); err == nil {
ts := d * float64(time.Second)
if ts > float64(math.MaxInt64) || ts < float64(math.MinInt64) {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration. It overflows int64", s)
}
return time.Duration(ts), nil
}
if d, err := promModel.ParseDuration(s); err == nil {
return time.Duration(d), nil
}
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration", s)
}

View File

@@ -4135,10 +4135,6 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
return readRowsForTimeSeriesResult(rows, vars, columnNames, countOfNumberCols)
}
func isJSONColumn(columnType driver.ColumnType) bool {
return strings.HasPrefix(strings.ToUpper(columnType.DatabaseTypeName()), "JSON")
}
// GetListResultV3 runs the query and returns list of rows
func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([]*v3.Row, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -4163,12 +4159,6 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
for rows.Next() {
var vars = make([]interface{}, len(columnTypes))
for i := range columnTypes {
if isJSONColumn(columnTypes[i]) {
// the driver fails to decode JSON into native Go values, so it is read as raw bytes
var raw []byte
vars[i] = &raw
continue
}
vars[i] = reflect.New(columnTypes[i].ScanType()).Interface()
}
if err := rows.Scan(vars...); err != nil {
@@ -4177,17 +4167,7 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
row := map[string]interface{}{}
var t time.Time
for idx, v := range vars {
if isJSONColumn(columnTypes[idx]) {
raw, ok := v.(*[]byte)
if !ok {
continue
}
var value map[string]interface{}
if err := json.Unmarshal(*raw, &value); err != nil {
return nil, errors.New(err.Error())
}
row[columnNames[idx]] = value
} else if columnNames[idx] == "timestamp" {
if columnNames[idx] == "timestamp" {
switch v := v.(type) {
case *uint64:
t = time.Unix(0, int64(*v))

View File

@@ -484,9 +484,6 @@ func (aH *APIHandler) Respond(w http.ResponseWriter, data interface{}) {
func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/prometheus/api/v1/query_range", am.ViewAccess(aH.Signoz.Handlers.PrometheusHandler.QueryRange)).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc("/prometheus/api/v1/query", am.ViewAccess(aH.Signoz.Handlers.PrometheusHandler.Query)).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
@@ -3806,10 +3803,6 @@ func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {
return
}
queryRangeParams.UseJSONBody = aH.Signoz.Flagger.BooleanOrEmpty(
r.Context(), flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID),
)
// add temporality for each metric
temporalityErr := aH.PopulateTemporality(r.Context(), orgID, queryRangeParams)
if temporalityErr != nil {

View File

@@ -361,7 +361,7 @@ func generateAggregateClause(panelType v3.PanelType, start, end int64, aggOp v3.
}
}
func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.BuilderQuery, graphLimitQtype string, useJSONBody bool) (string, error) {
func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.BuilderQuery, graphLimitQtype string) (string, error) {
// timerange will be sent in epoch millisecond
logsStart := utils.GetEpochNanoSecs(start)
logsEnd := utils.GetEpochNanoSecs(end)
@@ -405,9 +405,6 @@ func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.Build
if mq.AggregateOperator == v3.AggregateOperatorNoOp {
// with noop any filter or different order by other than ts will use new table
sqlSelect := constants.LogsSQLSelectV2
if useJSONBody {
sqlSelect = constants.LogsSQLSelectV2WithBodyJSON
}
queryTmpl := sqlSelect + "from signoz_logs.%s where %s%s order by %s"
query := fmt.Sprintf(queryTmpl, DISTRIBUTED_LOGS_V2, timeFilter, filterSubQuery, orderBy)
return query, nil
@@ -520,7 +517,7 @@ func PrepareLogsQuery(start, end int64, queryType v3.QueryType, panelType v3.Pan
return query, nil
} else if options.GraphLimitQtype == constants.FirstQueryGraphLimit {
// give me just the group_by names (no values)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
if err != nil {
return "", err
}
@@ -528,14 +525,14 @@ func PrepareLogsQuery(start, end int64, queryType v3.QueryType, panelType v3.Pan
return query, nil
} else if options.GraphLimitQtype == constants.SecondQueryGraphLimit {
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
if err != nil {
return "", err
}
return query, nil
}
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
if err != nil {
return "", err
}

View File

@@ -899,7 +899,7 @@ func Test_buildLogsQuery(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildLogsQuery(tt.args.panelType, tt.args.start, tt.args.end, tt.args.step, tt.args.mq, tt.args.graphLimitQtype, false)
got, err := buildLogsQuery(tt.args.panelType, tt.args.start, tt.args.end, tt.args.step, tt.args.mq, tt.args.graphLimitQtype)
if (err != nil) != tt.wantErr {
t.Errorf("buildLogsQuery() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -215,18 +215,18 @@ func (qb *QueryBuilder) PrepareQueries(params *v3.QueryRangeParamsV3) (map[strin
case v3.DataSourceLogs:
// for ts query with limit replace it as it is already formed
if compositeQuery.PanelType == v3.PanelTypeGraph && query.Limit > 0 && len(query.GroupBy) > 0 {
limitQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.FirstQueryGraphLimit, UseJSONBody: params.UseJSONBody})
limitQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.FirstQueryGraphLimit})
if err != nil {
return nil, err
}
placeholderQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.SecondQueryGraphLimit, UseJSONBody: params.UseJSONBody})
placeholderQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.SecondQueryGraphLimit})
if err != nil {
return nil, err
}
query := fmt.Sprintf(placeholderQuery, limitQuery)
queries[queryName] = query
} else {
queryString, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: "", UseJSONBody: params.UseJSONBody})
queryString, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: ""})
if err != nil {
return nil, err
}

View File

@@ -196,17 +196,13 @@ const (
"CAST((attributes_bool_key, attributes_bool_value), 'Map(String, Bool)') as attributes_bool," +
"CAST((resources_string_key, resources_string_value), 'Map(String, String)') as resources_string," +
"CAST((scope_string_key, scope_string_value), 'Map(String, String)') as scope "
logsSQLSelectV2Head = "SELECT " +
"timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, "
logsSQLSelectV2Tail = "attributes_string, " +
LogsSQLSelectV2 = "SELECT " +
"timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, " +
"attributes_string, " +
"attributes_number, " +
"attributes_bool, " +
"resources_string, " +
"scope_string "
LogsSQLSelectV2 = logsSQLSelectV2Head + "body, " + logsSQLSelectV2Tail
// Orgs on JSON bodies keep the body in body_v2 and have the body column written empty.
// Selected as JSON so the response carries the same body object v5 returns.
LogsSQLSelectV2WithBodyJSON = logsSQLSelectV2Head + "body_v2 as body, " + logsSQLSelectV2Tail
TracesExplorerViewSQLSelectWithSubQuery = "(SELECT traceID, durationNano, " +
"serviceName, name FROM %s.%s WHERE parentSpanID = '' AND %s ORDER BY durationNano DESC LIMIT 1 BY traceID"
TracesExplorerViewSQLSelectBeforeSubQuery = "SELECT subQuery.serviceName as `subQuery.serviceName`, subQuery.name as `subQuery.name`, count() AS " +

View File

@@ -435,8 +435,6 @@ type QueryRangeParamsV3 struct {
NoCache bool `json:"noCache"`
Version string `json:"-"`
FormatForWeb bool `json:"formatForWeb,omitempty"`
// Resolved from the use_json_body feature flag by the handler, never sent by clients.
UseJSONBody bool `json:"-"`
}
func (q *QueryRangeParamsV3) Clone() *QueryRangeParamsV3 {
@@ -452,7 +450,6 @@ func (q *QueryRangeParamsV3) Clone() *QueryRangeParamsV3 {
NoCache: q.NoCache,
Version: q.Version,
FormatForWeb: q.FormatForWeb,
UseJSONBody: q.UseJSONBody,
}
}
@@ -1472,5 +1469,4 @@ type MetricMetadataResponse struct {
type QBOptions struct {
GraphLimitQtype string
IsLivetailQuery bool
UseJSONBody bool
}

View File

@@ -48,8 +48,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/promapi"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
@@ -83,7 +81,6 @@ type Handlers struct {
RuleStateHistory rulestatehistory.Handler
SpanMapperHandler spanmapper.Handler
AlertmanagerHandler alertmanager.Handler
PrometheusHandler prometheus.Handler
TraceDetail tracedetail.Handler
RulerHandler ruler.Handler
LLMPricingRuleHandler llmpricingrule.Handler
@@ -104,7 +101,6 @@ func NewHandlers(
zeusService zeus.Zeus,
registryHandler factory.Handler,
alertmanagerService alertmanager.Alertmanager,
prometheusService prometheus.Prometheus,
rulerService ruler.Ruler,
statsAggregator statsreporter.Aggregator,
) Handlers {
@@ -133,7 +129,6 @@ func NewHandlers(
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
SpanMapperHandler: implspanmapper.NewHandler(modules.SpanMapper),
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
PrometheusHandler: promapi.NewHandler(providerSettings.Logger, prometheusService),
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
RulerHandler: signozruler.NewHandler(rulerService),
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),

View File

@@ -63,7 +63,7 @@ func TestNewHandlers(t *testing.T) {
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil)
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil)
reflectVal := reflect.ValueOf(handlers)
for i := 0; i < reflectVal.NumField(); i++ {
f := reflectVal.Field(i)

View File

@@ -617,7 +617,7 @@ func New(
// Initialize all handlers for the modules
registryHandler := factory.NewHandler(registry)
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, prometheus, rulerInstance, statsAggregator)
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance, statsAggregator)
// Initialize the API server (after registry so it can access service health)
apiserverInstance, err := factory.NewProviderFromNamedMap(

View File

@@ -469,8 +469,6 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
expectedErr: nil,
},
{
// The `[*]` path is extracted per value, not as an Array(String) compared to a
// scalar — ClickHouse rejects that outright (code 130).
name: "IN operator with json search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
@@ -481,7 +479,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSON_VALUE(body, '$.\"user_names\"[*]') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{querybuilder.NewKeyNotFoundWarning("user_names[*]")},
},

View File

@@ -94,11 +94,7 @@ func (c *conditionBuilder) conditionFor(
}
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.E(fieldExpression, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -108,11 +104,7 @@ func (c *conditionBuilder) conditionFor(
}
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.NE(fieldExpression, value))
}
return sb.And(conditions...), nil
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:

View File

@@ -410,11 +410,7 @@ func (c *conditionBuilder) conditionForResolvedKey(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionForResolvedKey(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.E(fieldExpression, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -425,11 +421,7 @@ func (c *conditionBuilder) conditionForResolvedKey(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionForResolvedKey(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.NE(fieldExpression, value))
}
return sb.And(conditions...), nil

View File

@@ -905,52 +905,3 @@ func TestConditionForJSONBodySearch(t *testing.T) {
})
}
}
// IN on the body column routes each value back through the `=` path; the SQL it produces
// must stay what the shared IN handling produced before, including for a mixed-type list.
func TestConditionForBodyIn(t *testing.T) {
testCases := []struct {
name string
values []any
expectedSQL string
expectedArgs []any
}{
{
name: "strings",
values: []any{"alpha", "beta"},
expectedSQL: "(body = ? OR body = ?)",
expectedArgs: []any{"alpha", "beta"},
},
{
name: "mixed types are stringified before they reach the column",
values: []any{"alpha", float64(1), true},
expectedSQL: "(body = ? OR body = ? OR body = ?)",
expectedArgs: []any{"alpha", "1", "true"},
},
}
fl := flaggertest.New(t)
fm := NewFieldMapper(fl)
conditionBuilder := NewConditionBuilder(fm, fl)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{
Name: "body",
FieldContext: telemetrytypes.FieldContextLog,
FieldDataType: telemetrytypes.FieldDataTypeString,
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("1").From("t")
cond, _, err := conditionBuilder.ConditionFor(context.Background(), valuer.UUID{}, 0, 0, &key,
map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, qbtypes.ConditionBuilderOptions{},
qbtypes.FilterOperatorIn, tc.values, sb)
require.NoError(t, err)
sb.Where(cond...)
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, tc.expectedSQL)
assert.Equal(t, tc.expectedArgs, args)
})
}
}

View File

@@ -135,11 +135,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using IN, we use `=` + `OR` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.E(fieldExpression, value))
}
return sb.Or(conditions...), nil
case qbtypes.FilterOperatorNotIn:
@@ -150,11 +146,7 @@ func (c *conditionBuilder) conditionFor(
// instead of using NOT IN, we use `!=` + `AND` to make use of index
conditions := []string{}
for _, value := range values {
cond, err := c.conditionFor(ctx, orgID, startNs, endNs, key, qbtypes.FilterOperatorNotEqual, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
conditions = append(conditions, sb.NE(fieldExpression, value))
}
return sb.And(conditions...), nil

View File

@@ -6,6 +6,6 @@ echo "Generating TypeScript parser..."
mkdir -p frontend/src/parser
# Generate TypeScript parser
(cd grammar && antlr4 -Dlanguage=TypeScript -o ../frontend/src/parser FilterQuery.g4 -visitor)
antlr4 -Dlanguage=TypeScript -o frontend/src/parser grammar/FilterQuery.g4 -visitor
echo "TypeScript parser generation complete"

View File

@@ -16,7 +16,6 @@ from wiremock.resources.mappings import (
from fixtures import reuse, types
from fixtures.logger import setup_logger
from fixtures.role import find_role_by_name
logger = setup_logger(__name__)
@@ -33,7 +32,6 @@ USER_VIEWER_EMAIL = "viewer@integration.test"
USER_VIEWER_PASSWORD = "password123Z$"
USERS_BASE = "/api/v2/users"
USER_ROLES_BASE = "/api/v2/user_roles"
def _login(signoz: types.SigNoz, email: str, password: str) -> str:
@@ -346,38 +344,24 @@ def create_active_user(
password: str,
name: str = "",
) -> str:
"""Create a pending invite user and activate it by setting a password.
role is a managed role name, e.g. signoz-viewer. Returns the user ID.
"""
"""Invite a user and activate via resetPassword. Returns user ID."""
response = requests.post(
signoz.self.host_configs["8080"].get(USERS_BASE),
json={
"email": email,
"displayName": name,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, role)}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/reset_password_tokens"),
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": role, "name": name},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": password, "token": response.json()["data"]["token"]},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": password, "token": invited_user["token"]},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
return user_id
return invited_user["id"]
def find_user_by_email(signoz: types.SigNoz, token: str, email: str) -> dict:
@@ -425,21 +409,21 @@ def change_user_role(
Role names should be managed role names (e.g. signoz-editor).
"""
# Get current roles to find the old role's user_role entry ID
# Get current roles to find the old role's ID
response = requests.get(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}"),
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
user_roles = response.json()["data"]["userRoles"]
roles = response.json()["data"]
old_role_entry = next((ur for ur in user_roles if ur["role"]["name"] == old_role), None)
old_role_entry = next((r for r in roles if r["name"] == old_role), None)
assert old_role_entry is not None, f"User does not have role '{old_role}'"
# Remove old role
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{USER_ROLES_BASE}/{old_role_entry['id']}"),
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/roles/{old_role_entry['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -447,9 +431,9 @@ def change_user_role(
# Assign new role
response = requests.post(
signoz.self.host_configs["8080"].get(USER_ROLES_BASE),
json={"userId": user_id, "roleId": find_role_by_name(signoz, admin_token, new_role)},
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/roles"),
json={"name": new_role},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
assert response.status_code == HTTPStatus.OK, response.text

12
tests/fixtures/idp.py vendored
View File

@@ -634,6 +634,18 @@ def get_oidc_domain(signoz: types.SigNoz, admin_token: str) -> dict:
)
def get_user_by_email(signoz: types.SigNoz, admin_token: str, email: str) -> dict:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
return next(
(user for user in response.json()["data"] if user["email"] == email),
None,
)
def perform_oidc_login(
signoz: types.SigNoz, # pylint: disable=unused-argument
idp: types.TestContainerIDP,

View File

@@ -1,66 +0,0 @@
import json
import math
import os
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from fixtures.metrics import Metrics
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "integration", "testdata", "promqltestcorpus")
CORPUS_FILE = os.path.join(TESTDATA_DIR, "corpus.json")
# Datasets sit on disjoint time windows (2h gaps, far beyond the 5m lookback)
# so one bulk ingest serves every case without cross-talk.
ISOLATION_GAP_MS = 2 * 3600 * 1000
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
def ingest_promqltest_corpus(insert_metrics: Callable[[list[Metrics]], None]) -> tuple[dict, dict[int, int]]:
"""Loads the frozen corpus, lays its datasets end to end on the timeline
(newest last, ending safely in the past), ingests every sample, and
returns (corpus, dataset base timestamps).
Dataset bases are hour-aligned: registration rows are hour-bucketed, so
behavior depends on where samples fall relative to hour boundaries, and
exact known-divergences enforcement needs identical placement every run."""
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)
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_ms = 3_600_000
advances = {ds["id"]: -(-(spans[ds["id"]] + ISOLATION_GAP_MS) // hour_ms) * hour_ms for ds in corpus["datasets"]}
total = sum(advances.values())
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
cursor = (int((now - timedelta(hours=1)).timestamp() * 1000) - total) // hour_ms * hour_ms
bases: dict[int, int] = {}
metrics: list[Metrics] = []
for ds in corpus["datasets"]:
bases[ds["id"]] = cursor
for series in ds["series"]:
labels = dict(series["labels"])
metric_name = labels.pop("__name__")
for off_ms, raw in series["samples"]:
stale = raw == "stale"
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=datetime.fromtimestamp((cursor + off_ms) / 1000, tz=UTC),
value=0.0 if stale else (SPECIALS[raw] if isinstance(raw, str) else float(raw)),
flags=1 if stale else 0,
)
)
cursor += advances[ds["id"]]
insert_metrics(metrics)
return corpus, bases

View File

@@ -9,14 +9,18 @@ from fixtures import types
from fixtures.fs import get_testdata_file_path
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
@pytest.fixture(name="find_role_id", scope="function")
def find_role_id(signoz: types.SigNoz) -> Callable[[str, str], str]:
def _find(token: str, name: str) -> str:
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
return _find
@pytest.fixture(name="create_role", scope="function")

View File

@@ -4,13 +4,22 @@ import requests
from fixtures import types
from fixtures.logger import setup_logger
from fixtures.role import find_role_by_name
logger = setup_logger(__name__)
SERVICE_ACCOUNT_BASE = "/api/v1/service_accounts"
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
def create_service_account(signoz: types.SigNoz, token: str, name: str, role: str = "signoz-viewer") -> str:
"""Create a service account, assign a role, and return its ID."""
resp = requests.post(

View File

@@ -13,14 +13,12 @@ from fixtures.auth import (
USER_ADMIN_PASSWORD,
add_license,
assert_user_has_role,
create_active_user,
find_user_with_roles_by_email,
)
from fixtures.idp import (
get_saml_domain,
perform_saml_login,
)
from fixtures.role import find_role_by_name
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
@@ -511,12 +509,8 @@ def test_saml_sso_login_activates_pending_invite_user(
# Invite user as ADMIN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": email,
"displayName": "SAML SSO Pending User",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-admin")}],
},
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "ADMIN", "name": "SAML SSO Pending User"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -553,14 +547,22 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# --- Step 1: Invite and activate via password reset ---
user_id = create_active_user(
signoz,
admin_token,
email=email,
role="signoz-editor",
password="password123Z$",
name="SAML SSO Lifecycle User",
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "EDITOR", "name": "SAML SSO Lifecycle User"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
user_id = response.json()["data"]["id"]
reset_token = response.json()["data"]["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# --- Step 2: Soft delete via DB using API
response = requests.delete(

View File

@@ -17,7 +17,6 @@ from fixtures.idp import (
get_oidc_domain,
perform_oidc_login,
)
from fixtures.role import find_role_by_name
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
@@ -465,12 +464,8 @@ def test_oidc_sso_login_activates_pending_invite_user(
# Invite user as ADMIN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": email,
"displayName": "OIDC SSO Pending User",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-admin")}],
},
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "ADMIN", "name": "OIDC SSO Pending User"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)

View File

@@ -12,7 +12,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import find_role_by_name, transaction_group
from fixtures.role import transaction_group
from fixtures.types import Operation, SigNoz, TestContainerDocker
V2_BASE_URL = "/api/v2/dashboards"
@@ -64,8 +64,8 @@ def test_setup_managed_role_users(
existing_emails = {user["email"] for user in response.json()["data"]}
for email, role, password, name in (
(_EDITOR_EMAIL, "signoz-editor", _EDITOR_PASSWORD, "dashboard authz editor"),
(_VIEWER_EMAIL, "signoz-viewer", _VIEWER_PASSWORD, "dashboard authz viewer"),
(_EDITOR_EMAIL, "EDITOR", _EDITOR_PASSWORD, "dashboard authz editor"),
(_VIEWER_EMAIL, "VIEWER", _VIEWER_PASSWORD, "dashboard authz viewer"),
):
if email not in existing_emails:
create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name)
@@ -411,7 +411,7 @@ def test_setup_scoped_actor(
],
)
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="signoz-viewer", password=_ACTOR_PASSWORD, name="dashboard fga actor")
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="VIEWER", password=_ACTOR_PASSWORD, name="dashboard fga actor")
change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME)
@@ -469,9 +469,10 @@ def test_publish_and_unpublish_require_update_on_the_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
actor_role_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
@@ -578,6 +579,7 @@ def test_dashboard_authz_cleanup(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor = find_user_by_email(signoz, admin_token, _ACTOR_EMAIL)
@@ -598,7 +600,7 @@ def test_dashboard_authz_cleanup(
assert response.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)}"),
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, _ACTOR_ROLE_NAME)}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -10,7 +10,7 @@ from wiremock.client import (
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, create_active_user
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.gateway import (
TEST_KEY_ID,
common_gateway_headers,
@@ -43,13 +43,21 @@ def test_create_editor_user(
"""Invite and register an editor user for gateway API tests."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_active_user(
signoz,
admin_token,
email=GATEWAY_APIS_EDITOR_EMAIL,
role="signoz-editor",
password=GATEWAY_APIS_EDITOR_PASSWORD,
invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": GATEWAY_APIS_EDITOR_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert invite_response.status_code == HTTPStatus.CREATED
reset_token = invite_response.json()["data"]["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": GATEWAY_APIS_EDITOR_PASSWORD, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# ---------------------------------------------------------------------------

View File

@@ -15,7 +15,6 @@ from fixtures.auth import (
find_user_with_roles_by_email,
)
from fixtures.logger import setup_logger
from fixtures.role import find_role_by_name
logger = setup_logger(__name__)
@@ -83,36 +82,31 @@ def test_register(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
def test_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create the editor user as a pending invite
# Generate an invite token for the editor user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": USER_EDITOR_EMAIL,
"displayName": USER_EDITOR_NAME,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": USER_EDITOR_EMAIL, "role": "EDITOR", "name": USER_EDITOR_NAME},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
invited_user = response.json()["data"]
assert invited_user["email"] == USER_EDITOR_EMAIL
assert invited_user["role"] == "EDITOR"
# Verify the user appears in the users list but as pending_invite status
found_user = find_user_with_roles_by_email(signoz, admin_token, USER_EDITOR_EMAIL)
assert found_user["status"] == "pending_invite"
assert_user_has_role(found_user, "signoz-editor")
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = invited_user["token"]
# Reset the password to complete the invite flow (activates the user and also grants authz)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": USER_EDITOR_PASSWORD, "token": response.json()["data"]["token"]},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": USER_EDITOR_PASSWORD, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -136,28 +130,18 @@ def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str
# Invite the viewer user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": USER_VIEWER_EMAIL,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-viewer")}],
},
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": USER_VIEWER_EMAIL, "role": "VIEWER"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = response.json()["data"]["token"]
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Delete the pending invite user (revoke the invite)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{invited_user['id']}"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
@@ -165,7 +149,7 @@ def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str
# Try to use the reset token — should fail (user deleted)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
@@ -173,83 +157,67 @@ def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str
def test_provision_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""Mirrors the zeus provisioning flow."""
"""
Simulates the upstream zeus provisioning flow:
1. Invite a user as ADMIN (register already happened via test_register)
2. List users to find the invited user's ID
3. Get reset password token for that user
4. Use the token to set the password and activate the user
5. Verify the user can log in
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
provisioned_email = "zeus-provisioned@integration.test"
provisioned_name = "zeus provisioned user"
provisioned_password = "password123Z$"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
role_id = next(role["id"] for role in response.json()["data"] if role["name"] == "signoz-admin")
create_payload = {
"email": provisioned_email,
"displayName": provisioned_name,
"userRoles": [{"id": role_id}],
}
# Step 1: Invite user as ADMIN (mirrors zeus inviteUserOnSigNoz)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json=create_payload,
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": provisioned_email,
"name": provisioned_name,
"role": "ADMIN",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json=create_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CONFLICT, response.text
# Step 2: List users to find the invited user's ID (mirrors zeus GET /api/v1/user)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
existing_id = next(user["id"] for user in response.json()["data"] if user["email"] == provisioned_email.strip().lower())
assert existing_id == user_id
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
found_user = next((u for u in users if u["email"] == provisioned_email), None)
assert found_user is not None
user_id = found_user["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
# Step 3: Get reset password token (mirrors zeus GET /api/v1/getResetPasswordToken/{id})
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/getResetPasswordToken/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
assert response.status_code == HTTPStatus.OK
reset_token = response.json()["data"]["token"]
assert reset_token is not None
assert reset_token != ""
# Step 4: Use the token to set password and activate user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": provisioned_password, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
assert response.status_code == HTTPStatus.NO_CONTENT
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={"email": provisioned_email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={"email": provisioned_email, "password": provisioned_password, "orgId": org_id},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["accessToken"] != ""
# Step 5: Verify the provisioned user can log in and is active with admin role
user_token = get_token(provisioned_email, provisioned_password)
assert user_token is not None
provisioned_user = find_user_with_roles_by_email(signoz, admin_token, provisioned_email)
assert provisioned_user["status"] == "active"

View File

@@ -5,7 +5,7 @@ import requests
from sqlalchemy import sql
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, create_active_user, find_user_by_email
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, find_user_by_email
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@@ -17,13 +17,24 @@ PASSWORD_USER_PASSWORD = "password123Z$"
def test_change_password(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_active_user(
signoz,
admin_token,
email=PASSWORD_USER_EMAIL,
role="signoz-admin",
password=PASSWORD_USER_PASSWORD,
# Create another admin user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": PASSWORD_USER_EMAIL, "role": "ADMIN"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Reset password to activate user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": PASSWORD_USER_PASSWORD, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try logging in with the password
token = get_token(PASSWORD_USER_EMAIL, PASSWORD_USER_PASSWORD)
@@ -99,7 +110,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
# Reset the password with a bad password which should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password", "token": token},
timeout=2,
)
@@ -108,19 +119,51 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
# Reset the password with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$NEWNEW#!", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
assert get_token(PASSWORD_USER_EMAIL, "password123Z$NEWNEW#!") is not None
token = get_token(PASSWORD_USER_EMAIL, "password123Z$NEWNEW#!")
assert token is not None
def test_reset_password_v2(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
found_user = find_user_by_email(signoz, admin_token, PASSWORD_USER_EMAIL)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
token = response.json()["data"]["token"]
# A password failing the strength policy is rejected without consuming the token
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "resetV2Password123Z$", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
assert get_token(PASSWORD_USER_EMAIL, "resetV2Password123Z$") is not None
# The token is single use, so replaying it no longer resolves
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$REPLAY#!", "token": token},
json={"password": "resetV2Password456Z$", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
@@ -161,7 +204,7 @@ def test_reset_password_with_no_password(signoz: types.SigNoz, get_token: Callab
# Reset the password with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "FINALPASSword123!#[", "token": token},
timeout=2,
)
@@ -219,14 +262,28 @@ def test_forgot_password_creates_reset_token(signoz: types.SigNoz, get_token: Ca
forgot_email = "forgot@integration.test"
# Create a user specifically for testing forgot password
create_active_user(
signoz,
admin_token,
email=forgot_email,
role="signoz-editor",
password="originalPassword123Z$",
name="forgotpassword user",
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": forgot_email,
"role": "EDITOR",
"name": "forgotpassword user",
},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Activate user via reset password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "originalPassword123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Get org ID
response = requests.get(
@@ -269,7 +326,7 @@ def test_forgot_password_creates_reset_token(signoz: types.SigNoz, get_token: Ca
# Reset password with a valid strong password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "newSecurePassword123Z$!", "token": reset_token},
timeout=2,
)
@@ -359,7 +416,7 @@ def test_reset_password_with_expired_token(signoz: types.SigNoz, get_token: Call
# Try to use the expired token - should fail with 401 Unauthorized
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "expiredTokenPassword123Z$!", "token": reset_token},
timeout=2,
)

View File

@@ -12,7 +12,6 @@ from fixtures.auth import (
change_user_role,
create_active_user,
)
from fixtures.role import find_role_by_name
ROLECHANGE_USER_EMAIL = "admin+rolechange@integration.test"
ROLECHANGE_USER_PASSWORD = "password123Z$"
@@ -24,14 +23,27 @@ def test_change_role(
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_active_user(
signoz,
admin_token,
email=ROLECHANGE_USER_EMAIL,
role="signoz-viewer",
password=ROLECHANGE_USER_PASSWORD,
# Create a new user as VIEWER
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": ROLECHANGE_USER_EMAIL, "role": "VIEWER"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Activate user via reset password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": ROLECHANGE_USER_PASSWORD, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Make some API calls as new user
new_user_token = get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)
@@ -121,7 +133,7 @@ def test_assign_role_is_additive(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify POST /api/v2/user_roles ADDS a role alongside existing ones and is idempotent."""
"""Verify POST /api/v2/users/{id}/roles ADDS a role alongside existing ones and is idempotent."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -132,17 +144,15 @@ def test_assign_role_is_additive(
me = response.json()["data"]
user_id = me["id"]
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
# User currently has signoz-admin from test_change_role.
# Assign signoz-editor — should be additive, admin stays.
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": editor_role_id},
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
json={"name": "signoz-editor"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.OK
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
@@ -158,12 +168,12 @@ def test_assign_role_is_additive(
# Idempotency: assigning the same role again succeeds without duplicates
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": editor_role_id},
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
json={"name": "signoz-editor"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED
assert response.status_code == HTTPStatus.OK
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
@@ -215,7 +225,7 @@ def test_remove_role(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify DELETE /api/v2/user_roles/{id} removes only the specified role."""
"""Verify DELETE /api/v2/users/{id}/roles/{roleId} removes only the specified role."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -226,11 +236,18 @@ def test_remove_role(
me = response.json()["data"]
user_id = me["id"]
editor_entry_id = next((ur["id"] for ur in me["userRoles"] if ur["role"]["name"] == "signoz-editor"), None)
assert editor_entry_id is not None
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
roles = response.json()["data"]
editor_role_id = next((r for r in roles if r["name"] == "signoz-editor"), None)["id"]
assert editor_role_id is not None
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{editor_entry_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles/{editor_role_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -279,7 +296,7 @@ def test_admin_cannot_assign_role_to_self(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify POST /api/v2/user_roles for the caller's own user is rejected (self-mutation guard)."""
"""Verify POST /api/v2/users/{own_id}/roles is rejected (self-mutation guard)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -290,8 +307,8 @@ def test_admin_cannot_assign_role_to_self(
admin_data = response.json()["data"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": admin_data["id"], "roleId": find_role_by_name(signoz, admin_token, "signoz-editor")},
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles"),
json={"name": "signoz-editor"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -302,7 +319,7 @@ def test_admin_cannot_remove_own_role(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify DELETE /api/v2/user_roles/{id} for the caller's own assignment is rejected (self-mutation guard)."""
"""Verify DELETE /api/v2/users/{own_id}/roles/{roleId} is rejected (self-mutation guard)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -312,11 +329,18 @@ def test_admin_cannot_remove_own_role(
assert response.status_code == HTTPStatus.OK
admin_data = response.json()["data"]
admin_entry_id = next((ur["id"] for ur in admin_data["userRoles"] if ur["role"]["name"] == "signoz-admin"), None)
assert admin_entry_id is not None
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
roles = response.json()["data"]
admin_role_id = next((r for r in roles if r["name"] == "signoz-admin"), None)["id"]
assert admin_role_id is not None
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{admin_entry_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles/{admin_role_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -335,7 +359,7 @@ def test_editor_cannot_manage_roles(
signoz,
admin_token,
email="viewer+roleauth@integration.test",
role="signoz-viewer",
role="VIEWER",
password=ROLECHANGE_USER_PASSWORD,
name="viewer roleauth",
)
@@ -352,8 +376,8 @@ def test_editor_cannot_manage_roles(
# POST assign role — forbidden
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": viewer_id, "roleId": find_role_by_name(signoz, admin_token, "signoz-editor")},
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles"),
json={"name": "signoz-editor"},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
@@ -361,15 +385,16 @@ def test_editor_cannot_manage_roles(
# DELETE remove role — forbidden
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
viewer_entry_id = next(ur["id"] for ur in response.json()["data"]["userRoles"] if ur["role"]["name"] == "signoz-viewer")
viewer_roles = response.json()["data"]
viewer_role_id = next((r for r in viewer_roles if r["name"] == "signoz-viewer"), None)["id"]
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{viewer_entry_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles/{viewer_role_id}"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)

View File

@@ -4,7 +4,6 @@ from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.role import find_role_by_name
from fixtures.types import SigNoz
DUPLICATE_USER_EMAIL = "duplicate@integration.test"
@@ -21,49 +20,38 @@ def test_duplicate_user_invite_rejected(
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
# Invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": DUPLICATE_USER_EMAIL,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Invite the same email again while still pending — should fail
# Invite the same email again — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={"email": DUPLICATE_USER_EMAIL, "userRoles": [{"id": viewer_role_id}]},
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CONFLICT
# activate the user
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$", "token": response.json()["data"]["token"]},
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try to invite the same email again once active — should fail
# Try to invite the same email again — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={"email": DUPLICATE_USER_EMAIL, "userRoles": [{"id": viewer_role_id}]},
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)

View File

@@ -3,12 +3,7 @@ from http import HTTPStatus
import requests
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
assert_user_has_role,
create_active_user,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, create_active_user
from fixtures.types import SigNoz
@@ -27,45 +22,71 @@ def test_reinvite_deleted_user(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
reinvite_user_email = "reinvite@integration.test"
reinvite_user_name = "reinvite user"
reinvite_user_role = "EDITOR"
reinvite_user_password = "password123Z$"
user_id = create_active_user(
signoz,
admin_token,
email=reinvite_user_email,
role="signoz-editor",
password="password123Z$",
name="reinvite user",
# invite the user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": reinvite_user_email,
"role": reinvite_user_role,
"name": reinvite_user_name,
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# reset the password to make it active
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": reinvite_user_password, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# call the delete api which now soft deletes the user
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{invited_user['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Re-invite the same email — should succeed and create a different user
reinvited_user_id = create_active_user(
signoz,
admin_token,
email=reinvite_user_email,
role="signoz-viewer",
password="newPassword123Z$",
name="reinvite user v2",
)
assert reinvited_user_id != user_id
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{reinvited_user_id}"),
# Re-invite the same email — should succeed
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": reinvite_user_email,
"role": "VIEWER",
"name": "reinvite user v2",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
assert_user_has_role(response.json()["data"], "signoz-viewer")
assert response.status_code == HTTPStatus.CREATED, response.text
reinvited_user = response.json()["data"]
assert reinvited_user["role"] == "VIEWER"
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
reinvited_user_reset_password_token = reinvited_user["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={
"password": "newPassword123Z$",
"token": reinvited_user_reset_password_token,
},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify user can log in with new password
user_token = get_token(reinvite_user_email, "newPassword123Z$")
user_token = get_token("reinvite@integration.test", "newPassword123Z$")
assert user_token is not None
@@ -84,7 +105,7 @@ def test_delete_user(
signoz,
admin_token,
email="delete-verify-v2@integration.test",
role="signoz-editor",
role="EDITOR",
password="password123Z$",
name="delete verify v2",
)

View File

@@ -8,7 +8,6 @@ from sqlalchemy import sql
from sqlalchemy.exc import IntegrityError
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.role import find_role_by_name
from fixtures.types import SigNoz
UNIQUE_INDEX_USER_EMAIL = "useruniqueindex@integration.test"
@@ -42,11 +41,11 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 1: invite and delete the first user
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"displayName": "unique index user v1",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
"role": "EDITOR",
"name": "unique index user v1",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
@@ -63,11 +62,11 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 2: re-invite and delete the same email (second deleted row)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"displayName": "unique index user v2",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
"role": "EDITOR",
"name": "unique index user v2",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,

View File

@@ -1,138 +0,0 @@
import json
import math
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.promqltestcorpus import ingest_promqltest_corpus
# The same frozen corpus the promqlconformance package replays through
# /api/v5/query_range, here replayed against the /prometheus/api/v1 endpoints
# with clickhousev2 as the serving provider (see conftest.py) — the two paths
# nothing else exercises. Range cases go to query_range, where a
# RangeExecutor provider serves transpiled statements when the shape allows.
# Instant cases go to /query with a real `time` parameter, so they need no
# grid encoding.
#
# Prometheus API sample values are strings, "NaN"/"+Inf"/"-Inf" included.
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "+Inf": math.inf, "-Inf": -math.inf}
QUERY_TIMEOUT = 30
def test_prometheus_api_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:
corpus, bases = ingest_promqltest_corpus(insert_metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
failures: list[str] = []
for case in corpus["cases"]:
# instant-coarse variants encode an instant eval as a coarse-step
# range because the v5 API cannot run true instants. This API can:
# the [base] form of the same eval goes through /query below, and the
# transpiled coarse-step serving the encoding exercises is covered
# (and its known divergences ledgered) by promqlconformance's
# clickhousev2 leg.
if case["variant"] == "instant-coarse":
continue
base = bases[case["dataset"]]
start_ms = base + case["start_ms"]
end_ms = base + case["end_ms"]
step_s = max(1, case["step_ms"] // 1000)
case_id = f"{case['source']}[{case['variant']}]"
if case["instant"]:
path, params = "/prometheus/api/v1/query", {"query": case["expr"], "time": end_ms / 1000}
else:
path, params = (
"/prometheus/api/v1/query_range",
{
"query": case["expr"],
"start": start_ms / 1000,
"end": end_ms / 1000,
"step": step_s,
},
)
response = requests.get(
signoz.self.host_configs["8080"].get(path),
params=params,
timeout=QUERY_TIMEOUT,
headers={"authorization": f"Bearer {token}"},
)
if response.status_code != HTTPStatus.OK:
failures.append(f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}")
continue
body = response.json()
if body.get("status") != "success":
failures.append(f"{case_id}: status {body.get('status')!r} for {case['expr']!r}: {json.dumps(body)[:200]}")
continue
result_type, result = body["data"]["resultType"], body["data"]["result"]
actual: dict[tuple, dict[int, float]] = {}
if result_type == "matrix":
for series in result:
points = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v) for ts, v in series.get("values") or []}
actual[tuple(sorted((series.get("metric") or {}).items()))] = points
elif result_type == "vector":
for series in result:
ts, v = series["value"]
actual[tuple(sorted((series.get("metric") or {}).items()))] = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v)}
elif result_type == "scalar":
ts, v = result
actual[()] = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v)}
expected: dict[tuple, dict[int, float]] = {}
for res in case["expected"]:
points = {base + off_ms: SPECIALS[v] if isinstance(v, str) else float(v) for off_ms, v in res["points"]}
expected[tuple(sorted(res["labels"].items()))] = points
if set(actual) != set(expected):
missing = set(expected) - set(actual)
extra = set(actual) - set(expected)
failures.append(f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]})")
continue
mismatch = None
for lset, exp_points in expected.items():
act_points = actual[lset]
if set(act_points) != set(exp_points):
mismatch = 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():
act_v = act_points[ts]
if math.isnan(act_v) or math.isnan(exp_v):
close = math.isnan(act_v) and math.isnan(exp_v)
elif math.isinf(act_v) or math.isinf(exp_v):
close = act_v == exp_v
elif act_v == exp_v:
close = True
else:
# Expected values carry the v5 API's rounding (>=1: three
# decimal places; <1: three significant digits); this API
# returns raw floats. One rounding quantum covers the
# largest possible rounding difference.
scale = max(abs(act_v), abs(exp_v))
if scale >= 1:
quantum = max(1e-3, scale * 1e-9)
else:
quantum = 10 ** (math.floor(math.log10(scale)) - 2)
close = abs(act_v - exp_v) <= quantum + 1e-12
if not close:
mismatch = f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_v}"
break
if mismatch:
break
if mismatch:
failures.append(mismatch)
for f_line in failures:
print("DIVERGED", f_line)
assert not failures, f"{len(failures)} corpus cases diverged:\n" + "\n".join(failures[:25])

View File

@@ -1,37 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_promapi_v2(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
SigNoz with clickhousev2 as the serving prometheus provider. The corpus
replays against the /prometheus/api/v1 endpoints, so this package covers
the two paths nothing else serves: v2 as the provider (range queries
transpile when the shape allows), and the Prometheus HTTP API contract.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-promapi-v2",
env_overrides={
"SIGNOZ_PROMETHEUS_PROVIDER": "clickhousev2",
},
)

View File

@@ -2,21 +2,21 @@ 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.promqltestcorpus import ingest_promqltest_corpus
from fixtures.querier import get_all_series, make_query_request
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
# The corpus (see fixtures/promqltestcorpus.py) is frozen from Prometheus' own
# promql/promqltest testdata by scripts/promqltestcorpus (upstream load scripts
# + the vendored reference engine). Unlike live-vs-live parity suites, the
# oracle is a committed file, so the suite 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.
# Frozen corpus extracted from Prometheus' own promql/promqltest testdata by
# scripts/promqltestcorpus (upstream load scripts + the vendored reference engine).
# Unlike live-vs-live parity suites, the oracle is this committed file, so the suite
# 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.
CORPUS_FILE = os.path.join(TESTDATA_DIR, "promqltestcorpus", "corpus.json")
# One ledger per leg, enforced exactly in both directions. The default leg's
# ledger is empty and pinned there; the clickhousev2 ledger is the rollout
@@ -40,6 +40,9 @@ LEGS: list[tuple[str, dict | None]] = [
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
]
# Datasets sit on disjoint time windows (2h gaps, far beyond the 5m lookback) so
# one bulk ingest serves every case without cross-talk.
ISOLATION_GAP_MS = 2 * 3600 * 1000
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
@@ -49,7 +52,51 @@ def test_upstream_promqltest_corpus(
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
corpus, bases = ingest_promqltest_corpus(insert_metrics)
with open(CORPUS_FILE, encoding="utf-8") as f:
corpus = json.load(f)
cases_by_dataset: dict[int, list[dict]] = {}
for case in corpus["cases"]:
cases_by_dataset.setdefault(case["dataset"], []).append(case)
# Lay datasets end to end on the timeline, newest last, ending safely in
# the past; spans are per-dataset so the whole corpus stays within days.
spans = {}
for ds in corpus["datasets"]:
sample_max = max((s["samples"][-1][0] for s in ds["series"] if s["samples"]), default=0)
case_max = max((c["end_ms"] for c in cases_by_dataset.get(ds["id"], [])), default=0)
spans[ds["id"]] = max(sample_max, case_max) + corpus["meta"]["lookback_ms"]
# Hour-aligned dataset bases: registration rows are hour-bucketed, so
# behavior depends on where samples fall relative to hour boundaries —
# the exact known-divergences enforcement needs that identical every run.
hour_ms = 3_600_000
advances = {ds["id"]: -(-(spans[ds["id"]] + ISOLATION_GAP_MS) // hour_ms) * hour_ms for ds in corpus["datasets"]}
total = sum(advances.values())
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
cursor = (int((now - timedelta(hours=1)).timestamp() * 1000) - total) // hour_ms * hour_ms
bases: dict[int, int] = {}
metrics: list[Metrics] = []
for ds in corpus["datasets"]:
bases[ds["id"]] = cursor
for series in ds["series"]:
labels = dict(series["labels"])
metric_name = labels.pop("__name__")
for off_ms, raw in series["samples"]:
stale = raw == "stale"
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=datetime.fromtimestamp((cursor + off_ms) / 1000, tz=UTC),
value=0.0 if stale else (SPECIALS[raw] if isinstance(raw, str) else float(raw)),
flags=1 if stale else 0,
)
)
cursor += advances[ds["id"]]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
failures: dict[str, list[str]] = {leg: [] for leg, _ in LEGS}

View File

@@ -41,7 +41,7 @@ def test_setup(
transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"]),
],
)
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="signoz-viewer", password=user_password)
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="VIEWER", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", scoped_role)

View File

@@ -24,11 +24,11 @@ def test_setup(
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="signoz-viewer", password=user_password)
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, builder_all_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])])
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="signoz-viewer", password=user_password)
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)

View File

@@ -38,15 +38,15 @@ def test_setup(
transaction_group("read", "telemetryresource", "meter-metrics", ["clickhouse_sql/*"]),
],
)
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="signoz-viewer", password=user_password)
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="signoz-viewer", password=user_password)
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)
# A plain managed viewer (signoz-viewer) — for the meter-metrics/audit-logs policy checks.
create_active_user(signoz, admin_token, email=viewer_email, role="signoz-viewer", password=user_password)
create_active_user(signoz, admin_token, email=viewer_email, role="VIEWER", password=user_password)
def test_clickhouse_sql_requires_chsql_grant(

View File

@@ -24,7 +24,7 @@ def test_setup(
) -> 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"])])
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="signoz-viewer", password=user_password)
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)

View File

@@ -21,7 +21,7 @@ def test_setup(
) -> 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="signoz-viewer", password=user_password)
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)

View File

@@ -3,13 +3,11 @@ from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import build_order_by, build_raw_query, get_rows, make_query_request
def test_logs_json_body_simple_searches(
@@ -913,61 +911,3 @@ def test_logs_json_body_listing(
assert len(results) == 1
count = results[0]["data"][0][0]
assert count == 4 # 4 logs have status="success"
@pytest.mark.parametrize(
"expression,expected_services",
[
pytest.param("body.service IN ['auth', 'payment']", {"auth", "payment"}, id="in_scalar_path"),
pytest.param("body.status IN [200, 500]", {"auth", "payment"}, id="in_number_path"),
pytest.param("body.service NOT IN ['auth']", {"payment", "search"}, id="not_in_scalar_path"),
# An `[]` path is extracted as an array. Comparing that array to each scalar in the
# list is something ClickHouse rejects outright (code 130), so this shape used to
# fail the whole query; per-value extraction reads the first element instead.
pytest.param("body.user_names[*] IN ['alpha', 'gamma']", {"auth", "payment"}, id="in_array_path"),
],
)
def test_logs_json_body_in_operator(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected_services: set[str],
) -> None:
"""IN over a body JSON path fans out to one comparison per value."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
specs = [("auth", 200, ["alpha", "beta"]), ("payment", 500, ["gamma"]), ("search", 404, ["beta", "alpha"])]
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": "api"},
body=json.dumps({"service": service, "status": status, "user_names": user_names}),
)
for i, (service, status, user_names) in enumerate(specs)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
build_raw_query(
"A",
"logs",
filter_expression=expression,
order=[build_order_by("timestamp", "desc")],
limit=100,
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
# flag off: the body comes back as the raw JSON string
assert {json.loads(row["data"]["body"])["service"] for row in get_rows(response)} == expected_services

View File

@@ -9,7 +9,6 @@ from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.role import (
expected_managed_transaction_keys,
find_role_by_name,
flatten_transaction_groups,
managed_role_names,
)
@@ -68,10 +67,11 @@ def test_managed_role_transactions_match_expected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
role_name: str,
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, role_name)
role_id = find_role_id(admin_token, role_name)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),

View File

@@ -12,7 +12,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import find_role_by_name, flatten_transaction_groups, transaction_group
from fixtures.role import flatten_transaction_groups, transaction_group
CRUD_ROLE_NAME = "crud-test-role"
CRUD_ASSIGNEE_ROLE_NAME = "crud-assignee-role"
@@ -61,9 +61,10 @@ def test_declarative_update_adds_and_removes_transactions(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, CRUD_ROLE_NAME)
role_id = find_role_id(admin_token, CRUD_ROLE_NAME)
def put_transactions(groups: list[dict]) -> None:
resp = requests.put(
@@ -207,9 +208,10 @@ def test_managed_role_is_immutable(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
admin_role_id = find_role_by_name(signoz, admin_token, "signoz-admin")
admin_role_id = find_role_id(admin_token, "signoz-admin")
resp = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{admin_role_id}"),
@@ -237,27 +239,27 @@ def test_delete_role_with_assignee_guarded(
signoz,
admin_token,
email=CRUD_ASSIGNEE_USER_EMAIL,
role="signoz-viewer",
role="VIEWER",
password=CRUD_ASSIGNEE_USER_PASSWORD,
name="crud-assignee-user",
)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": role_id},
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
json={"name": CRUD_ASSIGNEE_ROLE_NAME},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.CREATED, resp.text
assert resp.status_code == HTTPStatus.OK, resp.text
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"delete role with assignee: expected 400, got {resp.status_code}: {resp.text}"
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, resp.text
entry = next(ur for ur in resp.json()["data"]["userRoles"] if ur["role"]["name"] == CRUD_ASSIGNEE_ROLE_NAME)
entry = next(r for r in resp.json()["data"] if r["name"] == CRUD_ASSIGNEE_ROLE_NAME)
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{entry['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles/{entry['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -13,7 +13,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import find_role_by_name, transaction_group
from fixtures.role import transaction_group
_ACTOR_ROLE_NAME = "role-fga-actor"
_ACTOR_USER_EMAIL = "customrole+rolefga@integration.test"
@@ -57,7 +57,7 @@ def test_setup_actor_and_targets(
signoz,
admin_token,
email=_ACTOR_USER_EMAIL,
role="signoz-viewer",
role="VIEWER",
password=_ACTOR_USER_PASSWORD,
name="role-fga-test-user",
)
@@ -68,11 +68,12 @@ def test_read_scoped_to_granted_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_USER_EMAIL, _ACTOR_USER_PASSWORD)
a_id = find_role_by_name(signoz, admin_token, _TARGET_A)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
a_id = find_role_id(admin_token, _TARGET_A)
b_id = find_role_id(admin_token, _TARGET_B)
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{a_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, f"read granted role: {resp.text}"
@@ -100,10 +101,11 @@ def test_create_is_collection_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
token = get_token(_ACTOR_USER_EMAIL, _ACTOR_USER_PASSWORD)
resp = requests.post(
@@ -136,11 +138,12 @@ def test_update_scoped_to_granted_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_by_name(signoz, admin_token, _TARGET_A)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_id(admin_token, _TARGET_A)
b_id = find_role_id(admin_token, _TARGET_B)
resp = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
@@ -181,11 +184,12 @@ def test_delete_scoped_to_granted_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_by_name(signoz, admin_token, _TARGET_A)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_id(admin_token, _TARGET_A)
b_id = find_role_id(admin_token, _TARGET_B)
resp = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
@@ -217,10 +221,11 @@ def test_revoke_read(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
b_id = find_role_id(admin_token, _TARGET_B)
token = get_token(_ACTOR_USER_EMAIL, _ACTOR_USER_PASSWORD)
resp = requests.put(
@@ -256,6 +261,7 @@ def test_role_fga_cleanup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user = find_user_by_email(signoz, admin_token, _ACTOR_USER_EMAIL)
@@ -273,7 +279,7 @@ def test_role_fga_cleanup(
for name in (_ACTOR_ROLE_NAME, _TARGET_B, _CREATED_ROLE):
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, name)}"),
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, name)}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -13,7 +13,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import find_role_by_name, transaction_group
from fixtures.role import transaction_group
from fixtures.savedview import SAVED_VIEW_BASE, create_saved_view, find_saved_view_by_name
_SAVED_VIEW_FGA_CUSTOM_ROLE_NAME = "saved-view-fga-readonly"
@@ -57,7 +57,7 @@ def test_create_custom_role_readonly_view(
signoz,
admin_token,
email=_SAVED_VIEW_FGA_CUSTOM_USER_EMAIL,
role="signoz-viewer",
role="VIEWER",
password=_SAVED_VIEW_FGA_CUSTOM_USER_PASSWORD,
name="saved-view-fga-test-user",
)
@@ -152,9 +152,10 @@ def test_create_is_collection_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
target_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_TARGET_NAME)["id"]
resp = requests.put(
@@ -183,9 +184,10 @@ def test_update_scoped_to_granted_view(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
target_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_TARGET_NAME)["id"]
other_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_OTHER_NAME)["id"]
@@ -240,9 +242,10 @@ def test_delete_scoped_to_granted_view(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
target_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_TARGET_NAME)["id"]
other_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_OTHER_NAME)["id"]
@@ -276,9 +279,10 @@ def test_revoke_read_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
other_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_OTHER_NAME)["id"]
resp = requests.put(
@@ -315,6 +319,7 @@ def test_saved_view_fga_cleanup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user = find_user_by_email(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_USER_EMAIL)
@@ -331,7 +336,7 @@ def test_saved_view_fga_cleanup(
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {resp.text}"
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)}"),
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -13,7 +13,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import find_role_by_name, transaction_group
from fixtures.role import transaction_group
from fixtures.serviceaccount import (
SERVICE_ACCOUNT_BASE,
create_service_account,
@@ -70,7 +70,7 @@ def test_create_custom_role_readonly_sa(
signoz,
admin_token,
email=_SA_FGA_CUSTOM_USER_EMAIL,
role="signoz-viewer",
role="VIEWER",
password=_SA_FGA_CUSTOM_USER_PASSWORD,
name="sa-fga-test-user",
)
@@ -116,11 +116,12 @@ def test_write_forbidden_without_grant(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_SA_FGA_CUSTOM_USER_EMAIL, _SA_FGA_CUSTOM_USER_PASSWORD)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{target_id}"),
@@ -143,9 +144,10 @@ def test_update_scoped_to_granted_sa(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
other_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_OTHER_SA_NAME)["id"]
@@ -189,13 +191,14 @@ def test_attach_detach_dual_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
other_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_OTHER_SA_NAME)["id"]
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
editor_role_id = find_role_id(admin_token, "signoz-editor")
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
# attach/detach granted on the target SA id AND the signoz-editor role name only.
resp = requests.put(
@@ -258,9 +261,10 @@ def test_revoke_read_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
resp = requests.put(
@@ -280,9 +284,10 @@ def test_delete_custom_role_cleanup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
user = find_user_by_email(signoz, admin_token, _SA_FGA_CUSTOM_USER_EMAIL)
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)