Compare commits

..

21 Commits

Author SHA1 Message Date
Piyush Singariya
9299c8ab18 fix: indexed unit tests 2026-03-30 15:47:47 +05:30
Piyush Singariya
24749de269 fix: comment 2026-03-30 15:16:28 +05:30
Piyush Singariya
39098ec3f4 fix: unit tests 2026-03-30 15:12:17 +05:30
Piyush Singariya
fe554f5c94 fix: remove not used paths from testdata 2026-03-30 14:24:48 +05:30
Piyush Singariya
8a60a041a6 fix: unit tests 2026-03-30 14:14:49 +05:30
Piyush Singariya
541f19c34a fix: array type filtering from dynamic arrays 2026-03-30 12:59:31 +05:30
Piyush Singariya
010db03d6e fix: indexed tests passing 2026-03-30 12:24:26 +05:30
Piyush Singariya
5408acbd8c fix: primitive conditions working 2026-03-30 12:01:35 +05:30
Piyush Singariya
0de6c85f81 feat: align negative operators to include other logs 2026-03-28 10:30:11 +05:30
Piyush Singariya
69ec24fa05 test: fix unit tests 2026-03-27 15:12:49 +05:30
Piyush Singariya
539d732b65 fix: contextual path index usage 2026-03-27 14:44:51 +05:30
Piyush Singariya
843d5fb199 Merge branch 'main' into feat/json-index 2026-03-27 14:17:52 +05:30
Piyush Singariya
fabdfb8cc1 feat: enable JSON Path index 2026-03-27 14:07:37 +05:30
Nityananda Gohain
f3e6892d5b fix: remove flakyness for trace waterfall tests (#10734) 2026-03-27 06:50:42 +00:00
Nityananda Gohain
23a4960e74 chore: don't run functions if the series is empty (#10725)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: funcRunningDiff don't panic when series is empty

* chore: don't run functions if the series is empty
2026-03-27 04:41:00 +00:00
Vinicius Lourenço
5d0c55d682 fix(alerts-history): formatTime expecting number but receiving string (#10719)
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-03-27 00:52:40 +00:00
Nityananda Gohain
15704e0433 chore: cleanup traversal in trace waterfall (#10706)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-03-26 11:46:45 +00:00
primus-bot[bot]
5db0501c02 chore(release): bump to v0.117.1 (#10721)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
Co-authored-by: Priyanshu Shrivastava <priyanshu@signoz.io>
2026-03-26 10:01:46 +00:00
Tushar Vats
73da474563 fix: select column option in export button (#10709)
* fix: all option in trace export

* fix: remove the hack, user can select fields

* fix: hide column selection for trace export
2026-03-26 09:11:23 +00:00
Srikanth Chekuri
028c134ea9 chore: reject empty aggregations in payload regardless of disabled st… (#10720)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: reject empty aggregations in payload regardless of disabled status

* chore: update tests

* chore: count -> count()
2026-03-26 05:14:21 +00:00
Ashwin Bhatkal
31b61a89fd fix: collapsed panels not expanding (#10716)
* fix: collapsed panels not expanding

* fix: breaking logs when ordering by timestamp and not filtering on id
2026-03-26 04:06:18 +00:00
41 changed files with 1822 additions and 1465 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.117.0
image: signoz/signoz:v0.117.1
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.117.0
image: signoz/signoz:v0.117.1
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.117.0}
image: signoz/signoz:${VERSION:-v0.117.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.117.0}
image: signoz/signoz:${VERSION:-v0.117.1}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -116,7 +116,12 @@ describe.each([
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
if (dataSource === DataSource.TRACES) {
expect(screen.queryByText('Columns')).not.toBeInTheDocument();
} else {
expect(screen.getByText('Columns')).toBeInTheDocument();
}
});
it('allows changing export format', () => {
@@ -146,6 +151,17 @@ describe.each([
});
it('allows changing columns scope', () => {
if (dataSource === DataSource.TRACES) {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
expect(screen.queryByRole('radio', { name: 'All' })).not.toBeInTheDocument();
expect(
screen.queryByRole('radio', { name: 'Selected' }),
).not.toBeInTheDocument();
return;
}
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
@@ -210,7 +226,12 @@ describe.each([
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
// For traces, column scope is always Selected and the radio is hidden
if (dataSource !== DataSource.TRACES) {
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
}
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
@@ -227,6 +248,11 @@ describe.each([
});
it('sends no selectFields when column scope is All', async () => {
// For traces, column scope is always Selected — this test only applies to other sources
if (dataSource === DataSource.TRACES) {
return;
}
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));

View File

@@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Popover, Radio, Tooltip, Typography } from 'antd';
import { TelemetryFieldKey } from 'api/v5/v5';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
@@ -14,10 +15,12 @@ import './DownloadOptionsMenu.styles.scss';
interface DownloadOptionsMenuProps {
dataSource: DataSource;
selectedColumns?: TelemetryFieldKey[];
}
export default function DownloadOptionsMenu({
dataSource,
selectedColumns,
}: DownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
@@ -35,9 +38,19 @@ export default function DownloadOptionsMenu({
await handleExportRawData({
format: exportFormat,
rowLimit,
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
clearSelectColumns:
dataSource !== DataSource.TRACES &&
columnsScope === DownloadColumnsScopes.ALL,
selectedColumns,
});
}, [exportFormat, rowLimit, columnsScope, handleExportRawData]);
}, [
exportFormat,
rowLimit,
columnsScope,
selectedColumns,
handleExportRawData,
dataSource,
]);
const popoverContent = useMemo(
() => (
@@ -72,18 +85,22 @@ export default function DownloadOptionsMenu({
</Radio.Group>
</div>
<div className="horizontal-line" />
{dataSource !== DataSource.TRACES && (
<>
<div className="horizontal-line" />
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<Radio.Group
value={columnsScope}
onChange={(e): void => setColumnsScope(e.target.value)}
>
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
</Radio.Group>
</div>
<div className="columns-scope">
<Typography.Text className="title">Columns</Typography.Text>
<Radio.Group
value={columnsScope}
onChange={(e): void => setColumnsScope(e.target.value)}
>
<Radio value={DownloadColumnsScopes.ALL}>All</Radio>
<Radio value={DownloadColumnsScopes.SELECTED}>Selected</Radio>
</Radio.Group>
</div>
</>
)}
<Button
type="primary"
@@ -97,7 +114,14 @@ export default function DownloadOptionsMenu({
</Button>
</div>
),
[exportFormat, rowLimit, columnsScope, isDownloading, handleExport],
[
exportFormat,
rowLimit,
columnsScope,
isDownloading,
handleExport,
dataSource,
],
);
return (

View File

@@ -16,9 +16,9 @@ function AverageResolutionCard({
}: TotalTriggeredCardProps): JSX.Element {
return (
<StatsCard
displayValue={formatTime(currentAvgResolutionTime)}
totalCurrentCount={currentAvgResolutionTime}
totalPastCount={pastAvgResolutionTime}
displayValue={formatTime(+currentAvgResolutionTime)}
totalCurrentCount={+currentAvgResolutionTime}
totalPastCount={+pastAvgResolutionTime}
title="Avg. Resolution Time"
timeSeries={timeSeries}
/>

View File

@@ -48,6 +48,7 @@ import DashboardEmptyState from './DashboardEmptyState/DashboardEmptyState';
import GridCard from './GridCard';
import { Card, CardContainer, ReactGridLayout } from './styles';
import {
applyRowCollapse,
hasColumnWidthsChanged,
removeUndefinedValuesFromLayout,
} from './utils';
@@ -268,13 +269,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
currentWidget.title = newTitle;
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
(e) => e.id !== currentSelectRowId,
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
);
updatedWidgets?.push(currentWidget);
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
data: {
@@ -316,88 +314,13 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
if (!selectedDashboard) {
return;
}
const rowProperties = { ...currentPanelMap[id] };
const updatedPanelMap = { ...currentPanelMap };
let updatedDashboardLayout = [...dashboardLayout];
if (rowProperties.collapsed === true) {
rowProperties.collapsed = false;
const widgetsInsideTheRow = rowProperties.widgets;
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
updatedDashboardLayout[j].y += maxY;
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({
...w,
y: w.y + maxY,
}));
}
}
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
} else {
rowProperties.collapsed = true;
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
let widgetsInsideTheRow: Layout[] = [];
let isPanelMapUpdated = false;
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
if (currentPanelMap[dashboardLayout[j].i]) {
rowProperties.widgets = widgetsInsideTheRow;
widgetsInsideTheRow = [];
isPanelMapUpdated = true;
break;
} else {
widgetsInsideTheRow.push(dashboardLayout[j]);
}
}
if (!isPanelMapUpdated) {
rowProperties.widgets = widgetsInsideTheRow;
}
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout[currentIdx];
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
updatedDashboardLayout[j].y += maxY;
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({
...w,
y: w.y + maxY,
}));
}
}
updatedDashboardLayout = updatedDashboardLayout.filter(
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
);
}
setCurrentPanelMap((prev) => ({
...prev,
...updatedPanelMap,
[id]: {
...rowProperties,
},
}));
setDashboardLayout(sortLayout(updatedDashboardLayout));
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
id,
dashboardLayout,
currentPanelMap,
);
setCurrentPanelMap((prev) => ({ ...prev, ...updatedPanelMap }));
setDashboardLayout(sortLayout(updatedLayout));
};
const handleDragStop: ItemCallback = (_, oldItem, newItem): void => {

View File

@@ -0,0 +1,181 @@
import { Layout } from 'react-grid-layout';
import { applyRowCollapse, PanelMap } from '../utils';
// Helper to produce deeply-frozen objects that mimic what zustand/immer returns.
function freeze<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj), (_, v) =>
typeof v === 'object' && v !== null ? Object.freeze(v) : v,
) as T;
}
// ─── fixtures ────────────────────────────────────────────────────────────────
const ROW_ID = 'row1';
/** A layout with one row followed by two widgets. */
function makeLayout(): Layout[] {
return [
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
];
}
/** panelMap where the row is expanded (collapsed = false, widgets = []). */
function makeExpandedPanelMap(): PanelMap {
return {
[ROW_ID]: { collapsed: false, widgets: [] },
};
}
/** panelMap where the row is collapsed (widgets stored inside). */
function makeCollapsedPanelMap(): PanelMap {
return {
[ROW_ID]: {
collapsed: true,
widgets: [
{ i: 'w1', x: 0, y: 1, w: 6, h: 4 },
{ i: 'w2', x: 6, y: 1, w: 6, h: 4 },
],
},
};
}
// ─── frozen-input guard (regression for zustand/immer read-only bug) ──────────
describe('applyRowCollapse does not mutate frozen inputs', () => {
it('does not throw when collapsing a row with frozen layout + panelMap', () => {
expect(() =>
applyRowCollapse(
ROW_ID,
freeze(makeLayout()),
freeze(makeExpandedPanelMap()),
),
).not.toThrow();
});
it('does not throw when expanding a row with frozen layout + panelMap', () => {
// Collapsed layout only has the row item; widgets live in panelMap.
const collapsedLayout = freeze([{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }]);
expect(() =>
applyRowCollapse(ROW_ID, collapsedLayout, freeze(makeCollapsedPanelMap())),
).not.toThrow();
});
it('leaves the original layout array untouched after collapse', () => {
const layout = makeLayout();
const originalY = layout[1].y; // w1.y before collapse
applyRowCollapse(ROW_ID, layout, makeExpandedPanelMap());
expect(layout[1].y).toBe(originalY);
});
it('leaves the original panelMap untouched after collapse', () => {
const panelMap = makeExpandedPanelMap();
applyRowCollapse(ROW_ID, makeLayout(), panelMap);
expect(panelMap[ROW_ID].collapsed).toBe(false);
});
});
// ─── collapse behaviour ───────────────────────────────────────────────────────
describe('applyRowCollapse collapsing a row', () => {
it('sets collapsed = true on the row entry', () => {
const { updatedPanelMap } = applyRowCollapse(
ROW_ID,
makeLayout(),
makeExpandedPanelMap(),
);
expect(updatedPanelMap[ROW_ID].collapsed).toBe(true);
});
it('stores the child widgets inside the panelMap entry', () => {
const { updatedPanelMap } = applyRowCollapse(
ROW_ID,
makeLayout(),
makeExpandedPanelMap(),
);
const ids = updatedPanelMap[ROW_ID].widgets.map((w) => w.i);
expect(ids).toContain('w1');
expect(ids).toContain('w2');
});
it('removes child widgets from the returned layout', () => {
const { updatedLayout } = applyRowCollapse(
ROW_ID,
makeLayout(),
makeExpandedPanelMap(),
);
const ids = updatedLayout.map((l) => l.i);
expect(ids).not.toContain('w1');
expect(ids).not.toContain('w2');
expect(ids).toContain(ROW_ID);
});
});
// ─── expand behaviour ─────────────────────────────────────────────────────────
describe('applyRowCollapse expanding a row', () => {
it('sets collapsed = false on the row entry', () => {
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
const { updatedPanelMap } = applyRowCollapse(
ROW_ID,
collapsedLayout,
makeCollapsedPanelMap(),
);
expect(updatedPanelMap[ROW_ID].collapsed).toBe(false);
});
it('restores child widgets to the returned layout', () => {
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
const { updatedLayout } = applyRowCollapse(
ROW_ID,
collapsedLayout,
makeCollapsedPanelMap(),
);
const ids = updatedLayout.map((l) => l.i);
expect(ids).toContain('w1');
expect(ids).toContain('w2');
});
it('restored child widgets appear in both the layout and the panelMap entry', () => {
const collapsedLayout: Layout[] = [{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 }];
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
ROW_ID,
collapsedLayout,
makeCollapsedPanelMap(),
);
// The previously-stored widgets should now be back in the live layout.
expect(updatedLayout.map((l) => l.i)).toContain('w1');
// The panelMap entry still holds a reference to them (stale until next collapse).
expect(updatedPanelMap[ROW_ID].widgets.map((w) => w.i)).toContain('w1');
});
});
// ─── y-offset adjustment ──────────────────────────────────────────────────────
describe('applyRowCollapse y-offset adjustments for rows below', () => {
it('shifts items below a second row down when the first row expands', () => {
const ROW2 = 'row2';
// Layout: row1 (y=0,h=1) | w1 (y=1,h=4) | row2 (y=5,h=1) | w3 (y=6,h=2)
const layout: Layout[] = [
{ i: ROW_ID, x: 0, y: 0, w: 12, h: 1 },
{ i: 'w1', x: 0, y: 1, w: 12, h: 4 },
{ i: ROW2, x: 0, y: 5, w: 12, h: 1 },
{ i: 'w3', x: 0, y: 6, w: 12, h: 2 },
];
const panelMap: PanelMap = {
[ROW_ID]: {
collapsed: true,
widgets: [{ i: 'w1', x: 0, y: 1, w: 12, h: 4 }],
},
[ROW2]: { collapsed: false, widgets: [] },
};
// Expanding row1 should push row2 and w3 down by the height of w1 (4).
const collapsedLayout = layout.filter((l) => l.i !== 'w1');
const { updatedLayout } = applyRowCollapse(ROW_ID, collapsedLayout, panelMap);
const row2Item = updatedLayout.find((l) => l.i === ROW2);
expect(row2Item?.y).toBe(5 + 4); // shifted by maxY = 4
});
});

View File

@@ -4,6 +4,122 @@ import { isEmpty, isEqual } from 'lodash-es';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
export type PanelMap = Record<
string,
{ widgets: Layout[]; collapsed: boolean }
>;
export interface RowCollapseResult {
updatedLayout: Layout[];
updatedPanelMap: PanelMap;
}
/**
* Pure function that computes the new layout and panelMap after toggling a
* row's collapsed state. All inputs are treated as immutable — no input object
* is mutated, so it is safe to pass frozen objects from the zustand store.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function applyRowCollapse(
id: string,
dashboardLayout: Layout[],
currentPanelMap: PanelMap,
): RowCollapseResult {
// Deep-copy the row's own properties so we can mutate our local copy.
const rowProperties = {
...currentPanelMap[id],
widgets: [...(currentPanelMap[id]?.widgets ?? [])],
};
// Shallow-copy each entry's widgets array so inner .map() calls are safe.
const updatedPanelMap: PanelMap = Object.fromEntries(
Object.entries(currentPanelMap).map(([k, v]) => [
k,
{ ...v, widgets: [...v.widgets] },
]),
);
let updatedDashboardLayout = [...dashboardLayout];
if (rowProperties.collapsed === true) {
// ── EXPAND ──────────────────────────────────────────────────────────────
rowProperties.collapsed = false;
const widgetsInsideTheRow = rowProperties.widgets;
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout.find((w) => w.i === id);
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
const idxCurrentRow = dashboardLayout.findIndex((w) => w.i === id);
for (let j = idxCurrentRow + 1; j < dashboardLayout.length; j++) {
updatedDashboardLayout[j] = {
...updatedDashboardLayout[j],
y: updatedDashboardLayout[j].y + maxY,
};
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
}
}
updatedDashboardLayout = [...updatedDashboardLayout, ...widgetsInsideTheRow];
} else {
// ── COLLAPSE ─────────────────────────────────────────────────────────────
rowProperties.collapsed = true;
const currentIdx = dashboardLayout.findIndex((w) => w.i === id);
let widgetsInsideTheRow: Layout[] = [];
let isPanelMapUpdated = false;
for (let j = currentIdx + 1; j < dashboardLayout.length; j++) {
if (currentPanelMap[dashboardLayout[j].i]) {
rowProperties.widgets = widgetsInsideTheRow;
widgetsInsideTheRow = [];
isPanelMapUpdated = true;
break;
} else {
widgetsInsideTheRow.push(dashboardLayout[j]);
}
}
if (!isPanelMapUpdated) {
rowProperties.widgets = widgetsInsideTheRow;
}
let maxY = 0;
widgetsInsideTheRow.forEach((w) => {
maxY = Math.max(maxY, w.y + w.h);
});
const currentRowWidget = dashboardLayout[currentIdx];
if (currentRowWidget && widgetsInsideTheRow.length) {
maxY -= currentRowWidget.h + currentRowWidget.y;
}
for (let j = currentIdx + 1; j < updatedDashboardLayout.length; j++) {
updatedDashboardLayout[j] = {
...updatedDashboardLayout[j],
y: updatedDashboardLayout[j].y + maxY,
};
if (updatedPanelMap[updatedDashboardLayout[j].i]) {
updatedPanelMap[updatedDashboardLayout[j].i].widgets = updatedPanelMap[
updatedDashboardLayout[j].i
].widgets.map((w) => ({ ...w, y: w.y + maxY }));
}
}
updatedDashboardLayout = updatedDashboardLayout.filter(
(widget) => !rowProperties.widgets.some((w: Layout) => w.i === widget.i),
);
}
updatedPanelMap[id] = { ...rowProperties };
return { updatedLayout: updatedDashboardLayout, updatedPanelMap };
}
export const removeUndefinedValuesFromLayout = (layout: Layout[]): Layout[] =>
layout.map((obj) =>
Object.fromEntries(

View File

@@ -92,7 +92,10 @@ function LogsActionsContainer({
/>
</div>
<div className="download-options-container">
<DownloadOptionsMenu dataSource={DataSource.LOGS} />
<DownloadOptionsMenu
dataSource={DataSource.LOGS}
selectedColumns={options?.selectColumns}
/>
</div>
<div className="format-options-container">
<LogsFormatOptionsMenu

View File

@@ -42,8 +42,15 @@ function LogsPanelComponent({
setPageSize(value);
setOffset(0);
setRequestData((prev) => {
const newQueryData = { ...prev.query };
newQueryData.builder.queryData[0].pageSize = value;
const newQueryData = {
...prev.query,
builder: {
...prev.query.builder,
queryData: prev.query.builder.queryData.map((qd, i) =>
i === 0 ? { ...qd, pageSize: value } : qd,
),
},
};
return {
...prev,
query: newQueryData,

View File

@@ -42,11 +42,19 @@ function Panel({
};
}
updatedQuery.builder.queryData[0].pageSize = 10;
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
const updatedQueryForList = {
...updatedQuery,
builder: {
...updatedQuery.builder,
queryData: updatedQuery.builder.queryData.map((qd, i) =>
i === 0 ? { ...qd, pageSize: 10 } : qd,
),
},
};
return {
query: updatedQuery,
query: updatedQueryForList,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {

View File

@@ -239,7 +239,10 @@ function ListView({
/>
</div>
<DownloadOptionsMenu dataSource={DataSource.TRACES} />
<DownloadOptionsMenu
dataSource={DataSource.TRACES}
selectedColumns={options?.selectColumns}
/>
<TraceExplorerControls
isLoading={isFetching}

View File

@@ -52,37 +52,44 @@ export const useGetQueryRange: UseGetQueryRange = (
!firstQueryData?.filters?.items.some((filter) => filter.key?.key === 'id') &&
firstQueryData?.orderBy[0].columnName === 'timestamp';
const modifiedRequestData = {
if (
isListWithSingleTimestampOrder &&
firstQueryData?.dataSource === DataSource.LOGS
) {
return {
...requestData,
graphType:
requestData.graphType === PANEL_TYPES.BAR
? PANEL_TYPES.TIME_SERIES
: requestData.graphType,
query: {
...requestData.query,
builder: {
...requestData.query.builder,
queryData: [
{
...firstQueryData,
orderBy: [
...(firstQueryData?.orderBy || []),
{
columnName: 'id',
order: firstQueryData?.orderBy[0]?.order,
},
],
},
],
},
},
};
}
return {
...requestData,
graphType:
requestData.graphType === PANEL_TYPES.BAR
? PANEL_TYPES.TIME_SERIES
: requestData.graphType,
};
// If the query is a list with a single timestamp order, we need to add the id column to the order by clause
if (
isListWithSingleTimestampOrder &&
firstQueryData?.dataSource === DataSource.LOGS
) {
modifiedRequestData.query.builder = {
...requestData.query.builder,
queryData: [
{
...firstQueryData,
orderBy: [
...(firstQueryData?.orderBy || []),
{
columnName: 'id',
order: firstQueryData?.orderBy[0]?.order,
},
],
},
],
};
}
return modifiedRequestData;
}, [requestData]);
const queryKey = useMemo(() => {

View File

@@ -3,7 +3,7 @@ import { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { message } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { prepareQueryRangePayloadV5, TelemetryFieldKey } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
@@ -14,6 +14,7 @@ interface ExportOptions {
format: string;
rowLimit: number;
clearSelectColumns: boolean;
selectedColumns?: TelemetryFieldKey[];
}
interface UseExportRawDataProps {
@@ -42,6 +43,7 @@ export function useExportRawData({
format,
rowLimit,
clearSelectColumns,
selectedColumns,
}: ExportOptions): Promise<void> => {
if (!stagedQuery) {
return;
@@ -50,6 +52,12 @@ export function useExportRawData({
try {
setIsDownloading(true);
const selectColumnsOverride = clearSelectColumns
? {}
: selectedColumns?.length
? { selectColumns: selectedColumns }
: {};
const exportQuery = {
...stagedQuery,
builder: {
@@ -59,7 +67,7 @@ export function useExportRawData({
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...(clearSelectColumns && { selectColumns: [] }),
...selectColumnsOverride,
})),
queryTraceOperator: (stagedQuery.builder.queryTraceOperator || []).map(
(traceOp) => ({
@@ -67,7 +75,7 @@ export function useExportRawData({
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...(clearSelectColumns && { selectColumns: [] }),
...selectColumnsOverride,
}),
),
},

View File

@@ -56,8 +56,8 @@ export interface AlertRuleStats {
totalPastTriggers: number;
currentTriggersSeries: CurrentTriggersSeries;
pastTriggersSeries: CurrentTriggersSeries | null;
currentAvgResolutionTime: number;
pastAvgResolutionTime: number;
currentAvgResolutionTime: string;
pastAvgResolutionTime: string;
currentAvgResolutionTimeSeries: CurrentTriggersSeries;
pastAvgResolutionTimeSeries: any | null;
}

View File

@@ -112,6 +112,12 @@ export function formatEpochTimestamp(epoch: number): string {
*/
export function formatTime(seconds: number): string {
seconds = +seconds;
if (Number.isNaN(seconds)) {
return '-';
}
const days = seconds / 86400;
if (days >= 1) {

View File

@@ -1,6 +1,7 @@
package tracedetail
import (
"maps"
"slices"
"sort"
@@ -63,26 +64,22 @@ func findIndexForSelectedSpanFromPreOrder(spans []*model.Span, selectedSpanId st
return selectedSpanIndex
}
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, uncollapsedSpans []string, isSelectedSpanIDUnCollapsed bool) (bool, []string) {
func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string) (bool, []string) {
spansFromRootToNode := []string{}
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
if node.SpanID == selectedSpanId {
if isSelectedSpanIDUnCollapsed && !slices.Contains(uncollapsedSpans, node.SpanID) {
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
}
return true, spansFromRootToNode
}
isPresentInSubtreeForTheNode := false
for _, child := range node.Children {
isPresentInThisSubtree, _spansFromRootToNode := getPathFromRootToSelectedSpanId(child, selectedSpanId, uncollapsedSpans, isSelectedSpanIDUnCollapsed)
isPresentInThisSubtree, _spansFromRootToNode := getPathFromRootToSelectedSpanId(child, selectedSpanId)
// if the interested node is present in the given subtree then add the span node to uncollapsed node list
if isPresentInThisSubtree {
if !slices.Contains(uncollapsedSpans, node.SpanID) {
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
}
isPresentInSubtreeForTheNode = true
spansFromRootToNode = append(spansFromRootToNode, _spansFromRootToNode...)
break
}
}
return isPresentInSubtreeForTheNode, spansFromRootToNode
@@ -92,7 +89,7 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, un
// throughout the recursion. Per-call state (level, isPartOfPreOrder, etc.)
// is passed as direct arguments.
type traverseOpts struct {
uncollapsedSpans []string
uncollapsedSpans map[string]struct{}
selectedSpanID string
}
@@ -134,7 +131,7 @@ func traverseTrace(span *model.Span, opts traverseOpts, level uint64, isPartOfPr
preOrderTraversal = append(preOrderTraversal, &nodeWithoutChildren)
}
isAlreadyUncollapsed := slices.Contains(opts.uncollapsedSpans, span.SpanID)
_, isAlreadyUncollapsed := opts.uncollapsedSpans[span.SpanID]
for index, child := range span.Children {
_childTraversal := traverseTrace(child, opts, level+1, isPartOfPreOrder && isAlreadyUncollapsed, index != (len(span.Children)-1))
preOrderTraversal = append(preOrderTraversal, _childTraversal...)
@@ -169,16 +166,28 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
var preOrderTraversal = make([]*model.Span, 0)
var rootServiceName, rootServiceEntryPoint string
updatedUncollapsedSpans := uncollapsedSpans
// create a map of uncollapsed spans for quick lookup
uncollapsedSpanMap := make(map[string]struct{})
for _, spanID := range uncollapsedSpans {
uncollapsedSpanMap[spanID] = struct{}{}
}
selectedSpanIndex := -1
for _, rootSpanID := range traceRoots {
if rootNode, exists := spanIdToSpanNodeMap[rootSpanID.SpanID]; exists {
_, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID, updatedUncollapsedSpans, isSelectedSpanIDUnCollapsed)
updatedUncollapsedSpans = append(updatedUncollapsedSpans, spansFromRootToNode...)
present, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID)
if present {
for _, spanID := range spansFromRootToNode {
if selectedSpanID == spanID && !isSelectedSpanIDUnCollapsed {
continue
}
uncollapsedSpanMap[spanID] = struct{}{}
}
}
opts := traverseOpts{
uncollapsedSpans: updatedUncollapsedSpans,
uncollapsedSpans: uncollapsedSpanMap,
selectedSpanID: selectedSpanID,
}
_preOrderTraversal := traverseTrace(rootNode, opts, 0, true, false)
@@ -223,5 +232,5 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
startIndex = 0
}
return preOrderTraversal[startIndex:endIndex], updatedUncollapsedSpans, rootServiceName, rootServiceEntryPoint
return preOrderTraversal[startIndex:endIndex], slices.Collect(maps.Keys(uncollapsedSpanMap)), rootServiceName, rootServiceEntryPoint
}

View File

@@ -183,7 +183,7 @@ func TestGetSelectedSpans_PathReturnedInUncollapsed(t *testing.T) {
spanMap := buildSpanMap(root)
spans, uncollapsed, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent", "selected"}, spanIDs(spans))
}
@@ -206,7 +206,7 @@ func TestGetSelectedSpans_SiblingsNotExpanded(t *testing.T) {
// children of root sort alphabetically: parent < unrelated; unrelated-child stays hidden
assert.Equal(t, []string{"root", "parent", "selected", "unrelated"}, spanIDs(spans))
// only the path nodes are tracked as uncollapsed — unrelated is not
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
assert.ElementsMatch(t, []string{"root", "parent"}, uncollapsed)
}
// An unknown selectedSpanID must not panic; returns a window from index 0.

View File

@@ -3,7 +3,9 @@ package telemetrylogs
import (
"fmt"
"slices"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -32,9 +34,25 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
// BuildCondition builds the full WHERE condition for body_v2 JSON paths
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
baseCond, err := c.emitPlannedCondition(operator, value, sb)
if err != nil {
return "", err
}
// path index
if operator.AddDefaultExistsFilter() {
pathIndex := fmt.Sprintf(`has(%s, '%s')`, schemamigrator.JSONPathsIndexExpr(LogsV2BodyV2Column), c.key.ArrayParentPaths()[0])
return sb.And(baseCond, pathIndex), nil
}
return baseCond, nil
}
func (c *jsonConditionBuilder) emitPlannedCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
// Build traversal + terminal recursively per-hop
conditions := []string{}
for _, node := range c.key.JSONPlan {
condition, err := c.emitPlannedCondition(node, operator, value, sb)
condition, err := c.recurseArrayHops(node, operator, value, sb)
if err != nil {
return "", err
}
@@ -44,164 +62,7 @@ func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperato
return sb.Or(conditions...), nil
}
// emitPlannedCondition handles paths with array traversal
func (c *jsonConditionBuilder) emitPlannedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
// Build traversal + terminal recursively per-hop
compiled, err := c.recurseArrayHops(node, operator, value, sb)
if err != nil {
return "", err
}
return compiled, nil
}
// buildTerminalCondition creates the innermost condition
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if node.TerminalConfig.ElemType.IsArray {
conditions := []string{}
// if the value type is not an array
// TODO(piyush): Confirm the Query built for Array case and add testcases for it later
if !c.valueType.IsArray {
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
if operator.IsStringSearchOperator() {
formattedValue := querybuilder.FormatValueForContains(value)
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
}
// switch operator for array membership checks
switch operator {
case qbtypes.FilterOperatorContains:
operator = qbtypes.FilterOperatorEqual
case qbtypes.FilterOperatorNotContains:
operator = qbtypes.FilterOperatorNotEqual
}
}
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
// or the conditions together
return sb.Or(conditions...), nil
}
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
}
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
// it handles the data type collisions and utilizes indexes for the condition if available
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
fieldPath := node.FieldPath()
conditions := []string{}
var formattedValue any = value
if operator.IsStringSearchOperator() {
formattedValue = querybuilder.FormatValueForContains(value)
}
elemType := node.TerminalConfig.ElemType
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
// utilize indexes for the condition if available
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
return index.Type == elemType && index.ColumnExpression == fieldPath
})
if elemType.IndexSupported && indexed {
indexedExpr := assumeNotNull(fieldPath, elemType)
emptyValue := func() any {
switch elemType {
case telemetrytypes.String:
return ""
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
return 0
default:
return nil
}
}()
// switch the operator and value for exists and not exists
switch operator {
case qbtypes.FilterOperatorExists:
operator = qbtypes.FilterOperatorNotEqual
value = emptyValue
case qbtypes.FilterOperatorNotExists:
operator = qbtypes.FilterOperatorEqual
value = emptyValue
default:
// do nothing
}
indexedExpr, indexedComparisonValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, indexedExpr, operator)
cond, err := c.applyOperator(sb, indexedExpr, operator, indexedComparisonValue)
if err != nil {
return "", err
}
// if qb has a definitive value, we can skip adding a condition to
// check the existence of the path in the json column
if value != emptyValue {
return cond, nil
}
conditions = append(conditions, cond)
// Switch operator to EXISTS since indexed paths on assumedNotNull, indexes will always have a default value
// So we flip the operator to Exists and filter the rows that actually have the value
operator = qbtypes.FilterOperatorExists
}
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
if len(conditions) > 1 {
return sb.And(conditions...), nil
}
return conditions[0], nil
}
// buildArrayMembershipCondition handles array membership checks
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
arrayPath := node.FieldPath()
localKeyCopy := *node.TerminalConfig.Key
// create typed array out of a dynamic array
filteredDynamicExpr := func() string {
// Change the field data type from []dynamic to the value type
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
c.valueType.StringValue(),
c.valueType.StringValue(),
baseArrayDynamicExpr)
}
typedArrayExpr := func() string {
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
}
var arrayExpr string
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
arrayExpr = filteredDynamicExpr()
} else {
arrayExpr = typedArrayExpr()
}
key := "x"
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
op, err := c.applyOperator(sb, fieldExpr, operator, value)
if err != nil {
return "", err
}
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
}
// recurseArrayHops recursively builds array traversal conditions
// buildPlanCondition recursively traverses a single JSONPlan and builds condition
func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if current == nil {
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
@@ -215,6 +76,33 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
return terminalCond, nil
}
// apply NOT at top level arrayExists so that any subsequent arrayExists fails we count it as true (matching log)
yes, operator := applyNotCondition(operator)
condition, err := c.buildAccessNodeBranches(current, operator, value, sb)
if err != nil {
return "", err
}
if yes {
return sb.Not(condition), nil
}
return condition, nil
}
func applyNotCondition(operator qbtypes.FilterOperator) (bool, qbtypes.FilterOperator) {
if operator.IsNegativeOperator() {
return true, operator.Inverse()
}
return false, operator
}
// buildAccessNodeBranches builds conditions for each branch of the access node
func (c *jsonConditionBuilder) buildAccessNodeBranches(current *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if current == nil {
return "", errors.NewInternalf(CodeArrayNavigationFailed, "navigation failed, current node is nil")
}
currAlias := current.Alias()
fieldPath := current.FieldPath()
// Determine availability of Array(JSON) and Array(Dynamic) at this hop
@@ -249,6 +137,213 @@ func (c *jsonConditionBuilder) recurseArrayHops(current *telemetrytypes.JSONAcce
return sb.Or(branches...), nil
}
// buildTerminalCondition creates the innermost condition
func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
if node.TerminalConfig.ElemType.IsArray {
// Note: here applyNotCondition will return true only if; top level path is an array; and operator is a negative operator
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
yes, operator := applyNotCondition(operator)
cond, err := c.buildTerminalArrayCondition(node, operator, value, sb)
if err != nil {
return "", err
}
if yes {
return sb.Not(cond), nil
}
return cond, nil
}
return c.buildPrimitiveTerminalCondition(node, operator, value, sb)
}
func getEmptyValue(elemType telemetrytypes.JSONDataType) any {
switch elemType {
case telemetrytypes.String:
return ""
case telemetrytypes.Int64, telemetrytypes.Float64, telemetrytypes.Bool:
return 0
default:
return nil
}
}
func (c *jsonConditionBuilder) terminalIndexedCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
fieldPath := node.FieldPath()
if strings.Contains(fieldPath, telemetrytypes.ArraySepSuffix) {
return "", errors.NewInternalf(CodeArrayNavigationFailed, "can not build index condition for array field %s", fieldPath)
}
elemType := node.TerminalConfig.ElemType
dynamicExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, elemType.StringValue())
indexedExpr := assumeNotNull(dynamicExpr)
// switch the operator and value for exists and not exists
switch operator {
case qbtypes.FilterOperatorExists:
operator = qbtypes.FilterOperatorNotEqual
value = getEmptyValue(elemType)
case qbtypes.FilterOperatorNotExists:
operator = qbtypes.FilterOperatorEqual
value = getEmptyValue(elemType)
default:
// do nothing
}
indexedExpr, formattedValue := querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, value, indexedExpr, operator)
cond, err := c.applyOperator(sb, indexedExpr, operator, formattedValue)
if err != nil {
return "", err
}
return cond, nil
}
// buildPrimitiveTerminalCondition builds the condition if the terminal node is a primitive type
// it handles the data type collisions and utilizes indexes for the condition if available
func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
fieldPath := node.FieldPath()
conditions := []string{}
// utilize indexes for the condition if available
//
// Note: Indexing code doesn't get executed for Array Nested fields because they can not be indexed
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
return index.Type == node.TerminalConfig.ElemType
})
if node.TerminalConfig.ElemType.IndexSupported && indexed {
indexCond, err := c.terminalIndexedCondition(node, operator, value, sb)
if err != nil {
return "", err
}
// if qb has a definitive value, we can skip adding a condition to
// check the existence of the path in the json column
if value != nil && value != getEmptyValue(node.TerminalConfig.ElemType) {
return indexCond, nil
}
conditions = append(conditions, indexCond)
// Switch operator to EXISTS except when operator is NOT EXISTS since
// indexed paths on assumedNotNull, indexes will always have a default
// value so we flip the operator to Exists and filter the rows that
// actually have the value
if operator != qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorExists
}
}
var formattedValue any = value
if operator.IsStringSearchOperator() {
formattedValue = querybuilder.FormatValueForContains(value)
}
fieldExpr := fmt.Sprintf("dynamicElement(%s, '%s')", fieldPath, node.TerminalConfig.ElemType.StringValue())
// if operator is negative and has a value comparison i.e. excluding EXISTS and NOT EXISTS, we need to assume that the field exists everywhere
//
// Note: here applyNotCondition will return true only if; top level path is being queried and operator is a negative operator
// Otherwise this code will be triggered by buildAccessNodeBranches; Where operator would've been already inverted if needed.
if node.IsNonNestedPath() {
yes, _ := applyNotCondition(operator)
if yes {
switch operator {
case qbtypes.FilterOperatorNotExists:
// skip
default:
fieldExpr = assumeNotNull(fieldExpr)
}
}
}
fieldExpr, formattedValue = querybuilder.DataTypeCollisionHandledFieldName(node.TerminalConfig.Key, formattedValue, fieldExpr, operator)
cond, err := c.applyOperator(sb, fieldExpr, operator, formattedValue)
if err != nil {
return "", err
}
conditions = append(conditions, cond)
if len(conditions) > 1 {
return sb.And(conditions...), nil
}
return conditions[0], nil
}
func (c *jsonConditionBuilder) buildTerminalArrayCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
conditions := []string{}
// if operator is a String search Operator, then we need to build one more String comparison condition along with the Strict match condition
if operator.IsStringSearchOperator() {
formattedValue := querybuilder.FormatValueForContains(value)
arrayCond, err := c.buildArrayMembershipCondition(node, operator, formattedValue, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
// switch operator for array membership checks
switch operator {
case qbtypes.FilterOperatorContains:
operator = qbtypes.FilterOperatorEqual
case qbtypes.FilterOperatorNotContains:
operator = qbtypes.FilterOperatorNotEqual
}
}
arrayCond, err := c.buildArrayMembershipCondition(node, operator, value, sb)
if err != nil {
return "", err
}
conditions = append(conditions, arrayCond)
if len(conditions) > 1 {
return sb.Or(conditions...), nil
}
return conditions[0], nil
}
// buildArrayMembershipCondition builds condition of the part where Arrays becomes primitive typed Arrays
// e.g. [300, 404, 500], and value operations will work on the array elements
func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytypes.JSONAccessNode, operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
arrayPath := node.FieldPath()
localKeyCopy := *node.TerminalConfig.Key
// create typed array out of a dynamic array
filteredDynamicExpr := func() string {
// Change the field data type from []dynamic to the value type
// since we've filtered the value type out of the dynamic array, we need to change the field data corresponding to the value type
localKeyCopy.FieldDataType = telemetrytypes.MappingJSONDataTypeToFieldDataType[telemetrytypes.ScalerTypeToArrayType[c.valueType]]
primitiveType := c.valueType.StringValue()
// check if value is an array
if c.valueType.IsArray {
primitiveType = c.valueType.ScalerType
}
baseArrayDynamicExpr := fmt.Sprintf("dynamicElement(%s, 'Array(Dynamic)')", arrayPath)
return fmt.Sprintf("arrayMap(x->dynamicElement(x, '%s'), arrayFilter(x->(dynamicType(x) = '%s'), %s))",
primitiveType,
primitiveType,
baseArrayDynamicExpr)
}
typedArrayExpr := func() string {
return fmt.Sprintf("dynamicElement(%s, '%s')", arrayPath, node.TerminalConfig.ElemType.StringValue())
}
var arrayExpr string
if node.TerminalConfig.ElemType == telemetrytypes.ArrayDynamic {
arrayExpr = filteredDynamicExpr()
} else {
arrayExpr = typedArrayExpr()
}
key := "x"
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
op, err := c.applyOperator(sb, fieldExpr, operator, value)
if err != nil {
return "", err
}
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
}
func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, fieldExpr string, operator qbtypes.FilterOperator, value any) (string, error) {
switch operator {
case qbtypes.FilterOperatorEqual:
@@ -310,6 +405,6 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
}
}
func assumeNotNull(column string, elemType telemetrytypes.JSONDataType) string {
return fmt.Sprintf("assumeNotNull(dynamicElement(%s, '%s'))", column, elemType.StringValue())
func assumeNotNull(fieldExpr string) string {
return fmt.Sprintf("assumeNotNull(%s)", fieldExpr)
}

File diff suppressed because one or more lines are too long

View File

@@ -113,6 +113,29 @@ const (
FilterOperatorNotContains
)
var operatorInverseMapping = map[FilterOperator]FilterOperator{
FilterOperatorEqual: FilterOperatorNotEqual,
FilterOperatorNotEqual: FilterOperatorEqual,
FilterOperatorGreaterThan: FilterOperatorLessThanOrEq,
FilterOperatorGreaterThanOrEq: FilterOperatorLessThan,
FilterOperatorLessThan: FilterOperatorGreaterThanOrEq,
FilterOperatorLessThanOrEq: FilterOperatorGreaterThan,
FilterOperatorLike: FilterOperatorNotLike,
FilterOperatorNotLike: FilterOperatorLike,
FilterOperatorILike: FilterOperatorNotILike,
FilterOperatorNotILike: FilterOperatorILike,
FilterOperatorBetween: FilterOperatorNotBetween,
FilterOperatorNotBetween: FilterOperatorBetween,
FilterOperatorIn: FilterOperatorNotIn,
FilterOperatorNotIn: FilterOperatorIn,
FilterOperatorExists: FilterOperatorNotExists,
FilterOperatorNotExists: FilterOperatorExists,
FilterOperatorRegexp: FilterOperatorNotRegexp,
FilterOperatorNotRegexp: FilterOperatorRegexp,
FilterOperatorContains: FilterOperatorNotContains,
FilterOperatorNotContains: FilterOperatorContains,
}
// AddDefaultExistsFilter returns true if addl exists filter should be added to the query
// For the negative predicates, we don't want to add the exists filter. Why?
// Say for example, user adds a filter `service.name != "redis"`, we can't interpret it
@@ -162,6 +185,10 @@ func (f FilterOperator) IsNegativeOperator() bool {
return true
}
func (f FilterOperator) Inverse() FilterOperator {
return operatorInverseMapping[f]
}
func (f FilterOperator) IsComparisonOperator() bool {
switch f {
case FilterOperatorGreaterThan, FilterOperatorGreaterThanOrEq, FilterOperatorLessThan, FilterOperatorLessThanOrEq:

View File

@@ -102,6 +102,10 @@ func (fn FunctionName) Validate() error {
// ApplyFunction applies the given function to the result data
func ApplyFunction(fn Function, result *TimeSeries) *TimeSeries {
if len(result.Values) == 0 {
return result
}
// Extract the function name and arguments
name := fn.Name
args := fn.Args

View File

@@ -599,6 +599,14 @@ func TestApplyFunction(t *testing.T) {
values []float64
want []float64
}{
{
name: "test with empty series",
function: Function{
Name: FunctionNameRunningDiff,
},
values: []float64{},
want: []float64{},
},
{
name: "cutOffMin function",
function: Function{

View File

@@ -206,8 +206,11 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
return nil
}
// At least one aggregation required for non-disabled queries
if len(q.Aggregations) == 0 && !q.Disabled {
// At least one aggregation required for aggregation queries, even if
// they are disabled, usually because they are used in formula
// regardless of use in formula, it's invalid to have empty Aggregations
// for aggregation request
if len(q.Aggregations) == 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"at least one aggregation is required",

View File

@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -31,7 +32,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
{
@@ -39,7 +47,12 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "B",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
},
@@ -61,7 +74,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
{
@@ -194,7 +214,14 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
{
@@ -232,7 +259,12 @@ func TestQueryRangeRequest_ValidateAllQueriesNotDisabled(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "sum(duration)",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
},
@@ -366,7 +398,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
{
@@ -374,7 +411,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Disabled: true,
Signal: telemetrytypes.SignalTraces,
Aggregations: []TraceAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalTraces,
},
},
},
@@ -396,7 +438,12 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[LogAggregation]{
Name: "X",
Disabled: true,
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{
Expression: "count()",
},
},
Signal: telemetrytypes.SignalLogs,
},
},
{
@@ -404,7 +451,14 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "X",
Disabled: true,
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{
MetricName: "test",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationMax,
},
},
Signal: telemetrytypes.SignalMetrics,
},
},
},
@@ -427,7 +481,9 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Name: "A",
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{Expression: "count()"},
{
Expression: "count()",
},
},
},
},
@@ -581,7 +637,9 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
Name: "A",
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{
{Expression: "count()"},
{
Expression: "count()",
},
},
},
},

View File

@@ -90,6 +90,11 @@ func (n *JSONAccessNode) FieldPath() string {
return n.Parent.Alias() + "." + key
}
// Returns true if the current node is a non-nested path
func (n *JSONAccessNode) IsNonNestedPath() bool {
return !strings.Contains(n.FieldPath(), ArraySep)
}
func (n *JSONAccessNode) BranchesInOrder() []JSONAccessBranchType {
return slices.SortedFunc(maps.Keys(n.Branches), func(a, b JSONAccessBranchType) int {
return strings.Compare(b.StringValue(), a.StringValue())

View File

@@ -4,69 +4,106 @@ package telemetrytypes
// Test JSON Type Set Data Setup
// ============================================================================
// TestJSONTypeSet returns a map of path->types for testing
// This represents the type information available in the test JSON structure
// TestJSONTypeSet returns a map of path->types for testing.
// This represents the type information available in the test JSON structure.
func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
types := map[string][]JSONDataType{
"user.name": {String},
"user.permissions": {ArrayString},
"user.age": {Int64, String},
"user.height": {Float64},
"education": {ArrayJSON},
"education[].name": {String},
"education[].type": {String, Int64},
"education[].internal_type": {String},
"education[].metadata.location": {String},
"education[].parameters": {ArrayFloat64, ArrayDynamic},
"education[].duration": {String},
"education[].mode": {String},
"education[].year": {Int64},
"education[].field": {String},
"education[].awards": {ArrayDynamic, ArrayJSON},
"education[].awards[].name": {String},
"education[].awards[].rank": {Int64},
"education[].awards[].medal": {String},
"education[].awards[].type": {String},
"education[].awards[].semester": {Int64},
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
"education[].awards[].participated[].type": {String},
"education[].awards[].participated[].field": {String},
"education[].awards[].participated[].project_type": {String},
"education[].awards[].participated[].project_name": {String},
"education[].awards[].participated[].race_type": {String},
"education[].awards[].participated[].team_based": {Bool},
"education[].awards[].participated[].team_name": {String},
"education[].awards[].participated[].team": {ArrayJSON},
"education[].awards[].participated[].members": {ArrayString},
"education[].awards[].participated[].team[].name": {String},
"education[].awards[].participated[].team[].branch": {String},
"education[].awards[].participated[].team[].semester": {Int64},
"interests": {ArrayJSON},
"interests[].type": {String},
"interests[].entities": {ArrayJSON},
"interests[].entities.application_date": {String},
"interests[].entities[].reviews": {ArrayJSON},
"interests[].entities[].reviews[].given_by": {String},
"interests[].entities[].reviews[].remarks": {String},
"interests[].entities[].reviews[].weight": {Float64},
"interests[].entities[].reviews[].passed": {Bool},
"interests[].entities[].reviews[].type": {String},
"interests[].entities[].reviews[].analysis_type": {Int64},
"interests[].entities[].reviews[].entries": {ArrayJSON},
"interests[].entities[].reviews[].entries[].subject": {String},
"interests[].entities[].reviews[].entries[].status": {String},
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata[].company": {String},
"interests[].entities[].reviews[].entries[].metadata[].experience": {Int64},
"interests[].entities[].reviews[].entries[].metadata[].unit": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions[].duration": {Int64, Float64},
"interests[].entities[].reviews[].entries[].metadata[].positions[].unit": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
"message": {String},
"tags": {ArrayString},
// ── user (primitives) ─────────────────────────────────────────────
"user.name": {String},
"user.permissions": {ArrayString},
"user.age": {Int64, String}, // Int64/String ambiguity
"user.height": {Float64},
"user.active": {Bool}, // Bool — not IndexSupported
// Deeper non-array nesting (a.b.c — no array hops)
"user.address.zip": {Int64},
// ── education[] ───────────────────────────────────────────────────
// Pattern: x[].y
"education": {ArrayJSON},
"education[].name": {String},
"education[].type": {String, Int64},
"education[].year": {Int64},
"education[].scores": {ArrayInt64},
"education[].parameters": {ArrayFloat64, ArrayDynamic},
// Pattern: x[].y[]
"education[].awards": {ArrayDynamic, ArrayJSON},
// Pattern: x[].y[].z
"education[].awards[].name": {String},
"education[].awards[].type": {String},
"education[].awards[].semester": {Int64},
// Pattern: x[].y[].z[]
"education[].awards[].participated": {ArrayDynamic, ArrayJSON},
// Pattern: x[].y[].z[].w
"education[].awards[].participated[].members": {ArrayString},
// Pattern: x[].y[].z[].w[]
"education[].awards[].participated[].team": {ArrayJSON},
// Pattern: x[].y[].z[].w[].v
"education[].awards[].participated[].team[].branch": {String},
// ── interests[] ───────────────────────────────────────────────────
"interests": {ArrayJSON},
"interests[].entities": {ArrayJSON},
"interests[].entities[].reviews": {ArrayJSON},
"interests[].entities[].reviews[].entries": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata[].positions": {ArrayJSON},
"interests[].entities[].reviews[].entries[].metadata[].positions[].name": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
// ── http-events[] ─────────────────────────────────────────────────
"http-events": {ArrayJSON},
"http-events[].request-info.host": {String},
// ── top-level primitives ──────────────────────────────────────────
"message": {String},
"http-status": {Int64, String}, // hyphen in root key, ambiguous
// ── top-level nested objects (no array hops) ───────────────────────
"response.time-taken": {Float64}, // hyphen inside nested key
}
return types, nil
}
// TestIndexedPathEntry is a path + JSON type pair representing a field
// backed by a ClickHouse skip index in the test data.
//
// Only non-array paths with IndexSupported types (String, Int64, Float64)
// are valid entries — arrays and Bool cannot carry a skip index.
//
// The ColumnExpression for each entry is computed at test-setup time from
// the access plan, since it depends on the column name (e.g. body_v2)
// which is unknown to this package.
type TestIndexedPathEntry struct {
Path string
Type JSONDataType
}
// TestIndexedPaths lists path+type pairs from TestJSONTypeSet that are
// backed by a JSON data type index. Test setup uses this to populate
// key.Indexes after calling SetJSONAccessPlan.
//
// Intentionally excluded:
// - user.active → Bool, IndexSupported=false
var TestIndexedPaths = []TestIndexedPathEntry{
// user primitives
{Path: "user.name", Type: String},
// user.address — deeper non-array nesting
{Path: "user.address.zip", Type: Int64},
// root-level with special characters
{Path: "http-status", Type: Int64},
{Path: "http-status", Type: String},
// root-level nested objects (no array hops)
{Path: "response.time-taken", Type: Float64},
}

View File

@@ -654,6 +654,19 @@ 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:
"""Helper to get a user by email."""
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

@@ -3,9 +3,6 @@ import os
from typing import Any
import isodate
import requests
from fixtures import types
# parses the given timestamp string from ISO format to datetime.datetime
@@ -34,104 +31,3 @@ def parse_duration(duration: Any) -> datetime.timedelta:
def get_testdata_file_path(file: str) -> str:
testdata_dir = os.path.join(os.path.dirname(__file__), "..", "testdata")
return os.path.join(testdata_dir, file)
def get_user_by_email(signoz: types.SigNoz, admin_token: str, email: str) -> dict:
"""Helper to get a user by email."""
headers = {"Authorization": f"Bearer {admin_token}"} if admin_token else {}
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
timeout=2,
headers=headers,
)
return next(
(user for user in response.json()["data"] if user["email"] == email),
None,
)
def get_user_role_names(signoz: types.SigNoz, admin_token: str, user_id: str) -> list:
"""Helper to get the user roles by user ID"""
headers = {"Authorization": f"Bearer {admin_token}"} if admin_token else {}
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
timeout=2,
headers=headers,
)
roles = response.json()["data"]
if not roles:
return []
return [role["name"] for role in roles]
def get_user_roles(signoz: types.SigNoz, admin_token: str, user_id: str) -> list:
"""Helper to get the user roles (full objects) by user ID"""
headers = {"Authorization": f"Bearer {admin_token}"} if admin_token else {}
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
timeout=2,
headers=headers,
)
return response.json()["data"] or []
def add_user_role(
signoz: types.SigNoz, admin_token: str, user_id: str, role_name: str
) -> None:
"""Helper to add a role to a user via POST /api/v2/users/{id}/roles"""
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
json={"name": role_name},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert (
response.status_code == 200
), f"failed to add role {role_name}: {response.text}"
def remove_user_role_by_name(
signoz: types.SigNoz, admin_token: str, user_id: str, role_name: str
) -> None:
"""Helper to remove a role from a user by role name"""
roles = get_user_roles(signoz, admin_token, user_id)
role_id = next((r["id"] for r in roles if r["name"] == role_name), None)
assert role_id is not None, f"role {role_name} not found for user {user_id}"
response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v2/users/{user_id}/roles/{role_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert (
response.status_code == 204
), f"failed to remove role {role_name}: {response.text}"
def set_user_roles(
signoz: types.SigNoz, admin_token: str, user_id: str, desired_role_names: list
) -> None:
"""Helper to set exact roles for a user using POST/DELETE endpoints"""
current_roles = get_user_roles(signoz, admin_token, user_id)
current_names = {r["name"] for r in current_roles}
desired_names = set(desired_role_names)
# Remove roles not in desired set
for role in current_roles:
if role["name"] not in desired_names:
response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v2/users/{user_id}/roles/{role['id']}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == 204
# Add roles not in current set
for name in desired_names:
if name not in current_names:
add_user_role(signoz, admin_token, user_id, name)

View File

@@ -12,8 +12,11 @@ from fixtures.auth import (
USER_ADMIN_PASSWORD,
add_license,
)
from fixtures.idputils import get_saml_domain, perform_saml_login
from fixtures.utils import get_user_by_email, get_user_role_names
from fixtures.idputils import (
get_saml_domain,
get_user_by_email,
perform_saml_login,
)
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
@@ -128,12 +131,26 @@ def test_saml_authn(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Assert that the user was created in signoz.
found_user = get_user_by_email(signoz, admin_token, "viewer@saml.integration.test")
assert found_user is not None
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
# Confirm role
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "viewer@saml.integration.test"
),
None,
)
assert found_user is not None
assert found_user["role"] == "VIEWER"
def test_idp_initiated_saml_authn(
@@ -165,14 +182,26 @@ def test_idp_initiated_saml_authn(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Assert that the user was created in signoz.
found_user = get_user_by_email(
signoz, admin_token, "viewer.idp.initiated@saml.integration.test"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert found_user is not None
# Confirm role
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "viewer.idp.initiated@saml.integration.test"
),
None,
)
assert found_user is not None
assert found_user["role"] == "VIEWER"
def test_saml_update_domain_with_group_mappings(
@@ -242,8 +271,7 @@ def test_saml_role_mapping_single_group_admin(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-admin" in found_user_role_names
assert found_user["role"] == "ADMIN"
def test_saml_role_mapping_single_group_editor(
@@ -269,8 +297,7 @@ def test_saml_role_mapping_single_group_editor(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
def test_saml_role_mapping_multiple_groups_highest_wins(
@@ -300,8 +327,7 @@ def test_saml_role_mapping_multiple_groups_highest_wins(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
def test_saml_role_mapping_explicit_viewer_group(
@@ -328,8 +354,7 @@ def test_saml_role_mapping_explicit_viewer_group(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
def test_saml_role_mapping_unmapped_group_uses_default(
@@ -355,8 +380,7 @@ def test_saml_role_mapping_unmapped_group_uses_default(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
def test_saml_update_domain_with_use_role_claim(
@@ -433,8 +457,7 @@ def test_saml_role_mapping_role_claim_takes_precedence(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-admin" in found_user_role_names
assert found_user["role"] == "ADMIN"
def test_saml_role_mapping_invalid_role_claim_fallback(
@@ -464,8 +487,7 @@ def test_saml_role_mapping_invalid_role_claim_fallback(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
def test_saml_role_mapping_case_insensitive(
@@ -495,8 +517,7 @@ def test_saml_role_mapping_case_insensitive(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-admin" in found_user_role_names
assert found_user["role"] == "ADMIN"
def test_saml_name_mapping(
@@ -524,8 +545,7 @@ def test_saml_name_mapping(
assert (
found_user["displayName"] == "Jane"
) # We are only mapping the first name here
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
def test_saml_empty_name_fallback(
@@ -550,8 +570,7 @@ def test_saml_empty_name_fallback(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
def test_saml_sso_login_activates_pending_invite_user(
@@ -594,8 +613,7 @@ def test_saml_sso_login_activates_pending_invite_user(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
assert found_user["status"] == "active"
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
def test_saml_sso_deleted_user_gets_new_user_on_login(
@@ -662,7 +680,7 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
# Verify a NEW active user was auto-provisioned via SSO
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
@@ -676,7 +694,4 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
)
assert found_user is not None
assert found_user["status"] == "active"
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert (
"signoz-viewer" in found_user_role_names
) # default role from SSO domain config
assert found_user["role"] == "VIEWER" # default role from SSO domain config

View File

@@ -11,8 +11,11 @@ from fixtures.auth import (
USER_ADMIN_PASSWORD,
add_license,
)
from fixtures.idputils import get_oidc_domain, perform_oidc_login
from fixtures.utils import get_user_by_email, get_user_role_names
from fixtures.idputils import (
get_oidc_domain,
get_user_by_email,
perform_oidc_login,
)
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
@@ -109,12 +112,26 @@ def test_oidc_authn(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Assert that the user was created in signoz.
found_user = get_user_by_email(signoz, admin_token, "viewer@oidc.integration.test")
assert found_user is not None
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
# Confirm role
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "viewer@oidc.integration.test"
),
None,
)
assert found_user is not None
assert found_user["role"] == "VIEWER"
def test_oidc_update_domain_with_group_mappings(
@@ -191,8 +208,7 @@ def test_oidc_role_mapping_single_group_admin(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-admin" in found_user_role_names
assert found_user["role"] == "ADMIN"
def test_oidc_role_mapping_single_group_editor(
@@ -218,8 +234,7 @@ def test_oidc_role_mapping_single_group_editor(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
def test_oidc_role_mapping_multiple_groups_highest_wins(
@@ -249,8 +264,7 @@ def test_oidc_role_mapping_multiple_groups_highest_wins(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-admin" in found_user_role_names
assert found_user["role"] == "ADMIN"
def test_oidc_role_mapping_explicit_viewer_group(
@@ -277,8 +291,7 @@ def test_oidc_role_mapping_explicit_viewer_group(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
def test_oidc_role_mapping_unmapped_group_uses_default(
@@ -304,8 +317,7 @@ def test_oidc_role_mapping_unmapped_group_uses_default(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
def test_oidc_update_domain_with_use_role_claim(
@@ -385,8 +397,7 @@ def test_oidc_role_mapping_role_claim_takes_precedence(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-admin" in found_user_role_names
assert found_user["role"] == "ADMIN"
def test_oidc_role_mapping_invalid_role_claim_fallback(
@@ -418,8 +429,7 @@ def test_oidc_role_mapping_invalid_role_claim_fallback(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
def test_oidc_role_mapping_case_insensitive(
@@ -449,8 +459,7 @@ def test_oidc_role_mapping_case_insensitive(
found_user = get_user_by_email(signoz, admin_token, email)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
def test_oidc_name_mapping(
@@ -473,13 +482,20 @@ def test_oidc_name_mapping(
)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
found_user = get_user_by_email(signoz, admin_token, email)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
found_user = next((u for u in users if u["email"] == email), None)
assert found_user is not None
# Keycloak concatenates firstName + lastName into "name" claim
assert found_user["displayName"] == "John Doe"
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names # Default role
assert found_user["role"] == "VIEWER" # Default role
def test_oidc_empty_name_uses_fallback(
@@ -502,12 +518,19 @@ def test_oidc_empty_name_uses_fallback(
)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
found_user = get_user_by_email(signoz, admin_token, email)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
found_user = next((u for u in users if u["email"] == email), None)
# User should still be created even with empty name
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"
# Note: displayName may be empty - this is a known limitation
@@ -547,9 +570,16 @@ def test_oidc_sso_login_activates_pending_invite_user(
signoz, idp, driver, get_session_context, idp_login, email, "password123"
)
# User should be active with VIEWER role from SSO
found_user = get_user_by_email(signoz, admin_token, email)
# User should be active with ADMIN role from invite, not VIEWER from SSO
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
found_user = next(
(user for user in response.json()["data"] if user["email"] == email),
None,
)
assert found_user is not None
assert found_user["status"] == "active"
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-viewer" in found_user_role_names
assert found_user["role"] == "VIEWER"

View File

@@ -5,7 +5,6 @@ import requests
from fixtures import types
from fixtures.logger import setup_logger
from fixtures.utils import get_user_by_email, get_user_role_names
logger = setup_logger(__name__)
@@ -75,10 +74,31 @@ def test_register(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
admin_token = get_token("admin@integration.test", "password123Z$")
found_user = get_user_by_email(signoz, admin_token, "admin@integration.test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "admin@integration.test"),
None,
)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-admin" in found_user_role_names
assert found_user["role"] == "ADMIN"
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{found_user["id"]}"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["role"] == "ADMIN"
def test_invite_and_register(
@@ -100,11 +120,21 @@ def test_invite_and_register(
assert invited_user["role"] == "EDITOR"
# Verify the user user appears in the users list but as pending_invite status
found_user = get_user_by_email(signoz, admin_token, "editor@integration.test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "editor@integration.test"),
None,
)
assert found_user is not None
assert found_user["status"] == "pending_invite"
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
reset_token = invited_user["token"]
@@ -122,7 +152,7 @@ def test_invite_and_register(
# Verify that an admin endpoint cannot be called by the editor user
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {editor_token}"},
)
@@ -130,12 +160,24 @@ def test_invite_and_register(
assert response.status_code == HTTPStatus.FORBIDDEN
# Verify that the editor user status has been updated to ACTIVE
admin_token = get_token("admin@integration.test", "password123Z$")
found_user = get_user_by_email(signoz, admin_token, "editor@integration.test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={
"Authorization": f"Bearer {get_token("admin@integration.test", "password123Z$")}"
},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "editor@integration.test"),
None,
)
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
assert found_user["role"] == "EDITOR"
assert found_user["displayName"] == "editor"
assert found_user["email"] == "editor@integration.test"
assert found_user["status"] == "active"
@@ -179,7 +221,25 @@ def test_self_access(
) -> None:
admin_token = get_token("admin@integration.test", "password123Z$")
found_user = get_user_by_email(signoz, admin_token, "editor@integration.test")
assert found_user is not None
found_user_role_names = get_user_role_names(signoz, admin_token, found_user["id"])
assert "signoz-editor" in found_user_role_names
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "editor@integration.test"),
None,
)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{found_user['id']}"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["role"] == "EDITOR"

View File

@@ -26,7 +26,7 @@ def test_api_key(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
@@ -85,7 +85,7 @@ def test_api_key_role(
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)
@@ -109,7 +109,7 @@ def test_api_key_role(
assert "token" in pat_response["data"]
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"SIGNOZ-API-KEY": f"{pat_response["data"]["token"]}"},
)

View File

@@ -6,7 +6,6 @@ from sqlalchemy import sql
from fixtures import types
from fixtures.logger import setup_logger
from fixtures.utils import get_user_by_email
logger = setup_logger(__name__)
@@ -36,10 +35,23 @@ def test_change_password(
assert response.status_code == HTTPStatus.NO_CONTENT
# Get the user id
found_user = get_user_by_email(
signoz, admin_token, "admin+password@integration.test"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "admin+password@integration.test"
),
None,
)
assert found_user is not None
# Try logging in with the password
token = get_token("admin+password@integration.test", "password123Z$")
@@ -88,10 +100,23 @@ def test_reset_password(
admin_token = get_token("admin@integration.test", "password123Z$")
# Get the user id for admin+password@integration.test
found_user = get_user_by_email(
signoz, admin_token, "admin+password@integration.test"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "admin+password@integration.test"
),
None,
)
assert found_user is not None
response = requests.get(
signoz.self.host_configs["8080"].get(
@@ -133,10 +158,23 @@ def test_reset_password_with_no_password(
admin_token = get_token("admin@integration.test", "password123Z$")
# Get the user id for admin+password@integration.test
found_user = get_user_by_email(
signoz, admin_token, "admin+password@integration.test"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(
user
for user in user_response
if user["email"] == "admin+password@integration.test"
),
None,
)
assert found_user is not None
with signoz.sqlstore.conn.connect() as conn:
result = conn.execute(
@@ -267,7 +305,17 @@ def test_forgot_password_creates_reset_token(
# Verify reset password token was created by querying the database
# First, get the user ID
found_user = get_user_by_email(signoz, admin_token, "forgot@integration.test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "forgot@integration.test"),
None,
)
assert found_user is not None
reset_token = None
@@ -323,7 +371,17 @@ def test_reset_password_with_expired_token(
admin_token = get_token("admin@integration.test", "password123Z$")
# Get user ID for the forgot@integration.test user
found_user = get_user_by_email(signoz, admin_token, "forgot@integration.test")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.OK
user_response = response.json()["data"]
found_user = next(
(user for user in user_response if user["email"] == "forgot@integration.test"),
None,
)
assert found_user is not None
# Get org ID

View File

@@ -4,12 +4,6 @@ from typing import Callable, Tuple
import requests
from fixtures import types
from fixtures.utils import (
add_user_role,
get_user_role_names,
remove_user_role_by_name,
set_user_roles,
)
def test_change_role(
@@ -46,7 +40,7 @@ def test_change_role(
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
@@ -64,13 +58,22 @@ def test_change_role(
assert response.status_code == HTTPStatus.FORBIDDEN
# Change the new user's role - add ADMIN, remove VIEWER
add_user_role(signoz, admin_token, new_user_id, "signoz-admin")
remove_user_role_by_name(signoz, admin_token, new_user_id, "signoz-viewer")
# Change the new user's role - move to ADMIN
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{new_user_id}"),
json={
"displayName": "role change user",
"role": "ADMIN",
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
# Make some API calls again
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
@@ -103,268 +106,3 @@ def test_change_role(
)
assert response.status_code == HTTPStatus.OK
def test_remove_all_roles(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
get_tokens: Callable[[str, str], Tuple[str, str]],
):
admin_token = get_token("admin@integration.test", "password123Z$")
# Create a new user as EDITOR
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "admin+noroles@integration.test", "role": "EDITOR"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
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": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Login and get user id
new_user_token, new_user_refresh_token = get_tokens(
"admin+noroles@integration.test", "password123Z$"
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.OK
new_user_id = response.json()["data"]["id"]
# Validate the user has the editor role
role_names = get_user_role_names(signoz, admin_token, new_user_id)
assert role_names is not None
assert "signoz-editor" in role_names
# Remove all roles via DELETE endpoint
set_user_roles(signoz, admin_token, new_user_id, [])
# Validate the user has no roles
role_names = get_user_role_names(signoz, admin_token, new_user_id)
assert role_names is None or len(role_names) == 0
# Old token should be invalidated after role change
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
# Token rotation should also fail for a user with no roles
# (the session endpoint requires roles to build an identity)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
json={
"refreshToken": new_user_refresh_token,
},
headers={"Authorization": f"Bearer {new_user_token}"},
timeout=2,
)
assert (
response.status_code != HTTPStatus.OK
), "token rotation should fail for user with no roles"
def test_multiple_roles(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
get_tokens: Callable[[str, str], Tuple[str, str]],
):
admin_token = get_token("admin@integration.test", "password123Z$")
# Create a new user as VIEWER
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "admin+multirole@integration.test", "role": "VIEWER"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED
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": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Login and get user id
new_user_token, new_user_refresh_token = get_tokens(
"admin+multirole@integration.test", "password123Z$"
)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.OK
new_user_id = response.json()["data"]["id"]
# Validate user starts with viewer role
role_names = get_user_role_names(signoz, admin_token, new_user_id)
assert role_names is not None
assert role_names == [
"signoz-viewer"
], f"expected ['signoz-viewer'], got {role_names}"
# As viewer, admin-only APIs should be forbidden
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
# Assign multiple roles: add editor (viewer already assigned)
add_user_role(signoz, admin_token, new_user_id, "signoz-editor")
# Validate user has both roles
role_names = get_user_role_names(signoz, admin_token, new_user_id)
assert role_names is not None
assert sorted(role_names) == [
"signoz-editor",
"signoz-viewer",
], f"expected ['signoz-editor', 'signoz-viewer'], got {sorted(role_names)}"
# Rotate token to pick up new roles
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
json={"refreshToken": new_user_refresh_token},
headers={"Authorization": f"Bearer {new_user_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
rotate_response = response.json()["data"]
new_user_token = rotate_response["accessToken"]
new_user_refresh_token = rotate_response["refreshToken"]
# Verify /me includes both roles
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.OK
me_role_names = sorted(
ur["role"]["name"] for ur in response.json()["data"]["userRoles"]
)
assert me_role_names == [
"signoz-editor",
"signoz-viewer",
], f"expected ['signoz-editor', 'signoz-viewer'] in /me, got {me_role_names}"
# Editor+viewer still cannot access admin-only APIs
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
# Add admin role (editor + viewer already assigned)
add_user_role(signoz, admin_token, new_user_id, "signoz-admin")
role_names = get_user_role_names(signoz, admin_token, new_user_id)
assert sorted(role_names) == [
"signoz-admin",
"signoz-editor",
"signoz-viewer",
], f"expected all three roles, got {sorted(role_names)}"
# Rotate token to pick up admin role
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
json={"refreshToken": new_user_refresh_token},
headers={"Authorization": f"Bearer {new_user_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
rotate_response = response.json()["data"]
new_user_token = rotate_response["accessToken"]
new_user_refresh_token = rotate_response["refreshToken"]
# Now with admin role, admin-only APIs should succeed
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.OK
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.OK
# Reduce back to single viewer role
set_user_roles(signoz, admin_token, new_user_id, ["signoz-viewer"])
role_names = get_user_role_names(signoz, admin_token, new_user_id)
assert role_names == [
"signoz-viewer"
], f"expected ['signoz-viewer'] after reduction, got {role_names}"
# Rotate token to pick up reduced roles
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/rotate"),
json={"refreshToken": new_user_refresh_token},
headers={"Authorization": f"Bearer {new_user_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
rotate_response = response.json()["data"]
new_user_token = rotate_response["accessToken"]
# After reducing to viewer, admin-only APIs should be forbidden again
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
timeout=2,
headers={"Authorization": f"Bearer {new_user_token}"},
)
assert response.status_code == HTTPStatus.FORBIDDEN

View File

@@ -52,7 +52,7 @@ def test_root_user_signoz_admin_assignment(
# Get the user from the /user/me endpoint and extract the id
user_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)

View File

@@ -47,7 +47,7 @@ def test_user_invite_accept_role_grant(
# Login with editor email and password
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
user_me_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=2,
)
@@ -102,7 +102,7 @@ def test_user_update_role_grant(
# Get the editor user's id
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
user_me_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=2,
)
@@ -120,37 +120,15 @@ def test_user_update_role_grant(
roles_data = roles_response.json()["data"]
org_id = roles_data[0]["orgId"]
# Add the viewer role to the user
add_response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_id}/roles"),
json={"name": "signoz-viewer"},
# Update the user's role to viewer
update_payload = {"role": "VIEWER"}
update_response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/user/{editor_id}"),
json=update_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert add_response.status_code == HTTPStatus.OK
# Get the editor role id so we can remove it
roles_list_response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{editor_id}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert roles_list_response.status_code == HTTPStatus.OK
editor_role_id = next(
r["id"]
for r in roles_list_response.json()["data"]
if r["name"] == "signoz-editor"
)
# Remove the editor role
remove_response = requests.delete(
signoz.self.host_configs["8080"].get(
f"/api/v2/users/{editor_id}/roles/{editor_role_id}"
),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert remove_response.status_code == HTTPStatus.NO_CONTENT
assert update_response.status_code == HTTPStatus.OK
# Check that user no longer has the editor role in the db
with signoz.sqlstore.conn.connect() as conn:
@@ -201,7 +179,7 @@ def test_user_delete_role_revoke(
# login with editor to get the user_id and check if user exists
editor_token = get_token(USER_EDITOR_EMAIL, USER_EDITOR_PASSWORD)
user_me_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
signoz.self.host_configs["8080"].get("/api/v1/user/me"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=2,
)
@@ -244,120 +222,3 @@ def test_user_delete_role_revoke(
else:
_user = f"user:organization/{org_id}/user/{editor_id}"
assert row["_user"] != _user
def test_update_my_user(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Invite a viewer user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "admin+updateme@integration.test", "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
reset_token = response.json()["data"]["token"]
# Activate user
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
# Login as viewer
viewer_token = get_token("admin+updateme@integration.test", "password123Z$")
# Update own display name via PUT /api/v2/users/me
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
json={"displayName": "viewer updated name"},
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify the update via GET /api/v2/users/me
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {viewer_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["displayName"] == "viewer updated name"
def test_update_user_by_id(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Invite a user to update
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": "admin+updatetest@integration.test", "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
reset_token = response.json()["data"]["token"]
# Activate user
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
# Get user id
user_token = get_token("admin+updatetest@integration.test", "password123Z$")
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {user_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
user_id = response.json()["data"]["id"]
# Admin updates user's display name via PUT /api/v2/users/{id}
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
json={"displayName": "renamed user"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify via GET /api/v2/users/{id}
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["data"]["displayName"] == "renamed user"
# Self-update should be rejected
me_response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
admin_id = me_response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_id}"),
json={"displayName": "self update"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST

View File

@@ -14,7 +14,7 @@ def test_root_user_created(signoz: types.SigNoz) -> None:
The root user service reconciles asynchronously after startup.
Phase 1: Poll /api/v1/version until setupCompleted=true.
Phase 2: Poll /api/v2/users until it returns 200, confirming the root
Phase 2: Poll /api/v1/user until it returns 200, confirming the root
user actually exists and the impersonation provider works.
"""
# Phase 1: wait for setupCompleted
@@ -39,13 +39,13 @@ def test_root_user_created(signoz: types.SigNoz) -> None:
# Phase 2: wait for root user to be fully resolved
for attempt in range(15):
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
)
if response.status_code == HTTPStatus.OK:
return
logger.info(
"Attempt %s: /api/v2/users returned %s, retrying ...",
"Attempt %s: /api/v1/user returned %s, retrying ...",
attempt + 1,
response.status_code,
)

View File

@@ -4,7 +4,6 @@ import requests
from fixtures import types
from fixtures.logger import setup_logger
from fixtures.utils import get_user_role_names
logger = setup_logger(__name__)
@@ -33,7 +32,7 @@ def test_impersonated_user_is_admin(signoz: types.SigNoz) -> None:
Listing users is an admin-only endpoint.
"""
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users"),
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
)
@@ -47,5 +46,4 @@ def test_impersonated_user_is_admin(signoz: types.SigNoz) -> None:
None,
)
assert root_user is not None
root_user_role_names = get_user_role_names(signoz, None, root_user["id"])
assert "signoz-admin" in root_user_role_names
assert root_user["role"] == "ADMIN"