Compare commits

..

7 Commits

Author SHA1 Message Date
vikrantgupta25
0c9b4711e4 fix: check user attach on reset token issue, list tokens collection-wide
PUT /api/v2/users/{id}/reset_password_tokens issues a credential for the
target user, so it now declares a parent-child def requiring attach on the
user alongside factor-password:create on the instance (mirroring the
/api/v1/service_accounts/{id}/keys pattern).

GET on the same path enumerates the user's tokens, so it checks
factor-password:list on the wildcard (list is a collection-level verb; the
path id is only a filter) instead of factor-password:read on the instance.
2026-09-09 18:39:52 +05:30
vikrantgupta25
13c92c0010 chore: empty commit to verify https push 2026-09-09 18:14:09 +05:30
vikrantgupta25
2f19c4811c Merge remote-tracking branch 'origin/main' into feat/user-api-authz
# Conflicts:
#	pkg/signoz/provider.go
2026-09-09 17:36:25 +05:30
vikrantgupta25
1168aff204 fix: skip the vacuous attach def when no roles are sent at user creation
POST /api/v2/users with an absent or empty userRoles list attaches zero
roles, yet the sibling attach def still demanded user:attach on user:* and
role:attach on role:* (the resolved-resource contract maps an unresolved id
to collection-level). Attaching zero roles exercises zero grants, so the
check 403'd least-privilege principals holding only user:create.

AttachDetachSiblingResourceDef gains an opt-in OptionalTargets flag: when
the request-phase target extractor resolves zero ids, the def resolves to
nothing and neither side is checked. The flag is set only on user creation,
where the role list is legitimately optional; routes where a missing target
is a client error keep the fail-closed empty-id contract. With the flag in
place, roleAttachSelector's wildcard branch is unreachable, so the route
uses roleSelector directly and the wrapper is dropped.
2026-09-09 17:28:40 +05:30
Gaurav Tewari
99dcd79979 chore(llm-observability): fork shared traces explorer files (#12795)
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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Copies, unmodified, the traces explorer files that the AI
observability explorer is about to diverge from. The point is the *next*
PR (#12796): with this baseline landed, that diff shows only the real
changes instead of ~1000 lines of brand-new files.
- No behaviour change. Nothing outside the fork imports the new copies
yet — the explorer's views are wired to them in #12796, alongside the
changes that need them.


Sources, all taken from this PR's merge base:

| Copied to `container/LLMObservability/` | Source |
| --- | --- |
| `ToolbarActions/LeftToolbarActions.tsx` |
`container/QueryBuilder/components/ToolbarActions/LeftToolbarActions.tsx`
|
| `ToolbarActions/ToolbarActions.styles.scss` |
`container/QueryBuilder/components/ToolbarActions/ToolbarActions.styles.scss`
|
| `Explorer/aiActions.ts` | `pages/TracesExplorer/aiActions.ts` |
| `Explorer/Controls/*` | `container/TracesExplorer/Controls/*` |
| `Explorer/TraceLoading/*` | `container/TracesExplorer/TraceLoading/*`
|
| `Explorer/TracesTable/*` | `container/TracesExplorer/TracesTable/*` |
| `Explorer/ListView/utils.tsx` |
`container/TracesExplorer/ListView/utils.tsx` |

#### Issues closed by this PR

#### Screenshots / Screen Recordings

#### Additional Information

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-09 09:40:13 +00:00
vikrantgupta25
bff855a05f fix: require attach grants to assign roles at user creation
POST /api/v2/users accepts userRoles in the payload, so creating a user
is also an attach between the new user and each role. The route now
declares a sibling attach def alongside the create def: attach on the
user resource (collection-level, the instance does not exist yet) and
attach on every role id in userRoles, resolved to role names. An absent
or empty userRoles list falls back to the collection-level role check,
per the resolved-resource contract (empty id means collection-level).
2026-09-08 02:23:29 +05:30
vikrantgupta25
337e62c775 feat(authz): enable FGA for the users API
#### Description

- Moves the users API off the legacy AdminAccess role gate onto
  CheckResources + ResourceDefs, so the routes work with per-resource
  OpenFGA checks on enterprise and keep the signoz-admin role gate on
  community:
  - /api/v2/users CRUD checks the user resource (create/list wildcard,
    read/update/delete per-instance).
  - /api/v2/users/{id}/reset_password_tokens checks the factor-password
    metaresource (read/create) against the user instance.
  - /api/v2/users/{id}/roles and /api/v2/user_roles check user
    read/attach/detach; the role side of the assignments is checked via
    the sibling attach/detach def, resolving role names from role ids.
  - /api/v2/roles/{id}/users checks role read.
  - Self-service and anonymous flows (users/me, password
    forgot/reset/verify/change) stay OpenAccess.
- The user, role and factor-password kinds/resources/managed-role
  transactions already existed in the registries, so no registry or
  schema changes are needed.
- Migration 126_add_user_tuples backfills the admin user and
  factor-password tuples for existing organizations; new organizations
  get them from the registry at bootstrap. The managed-role transaction
  groups stored per org already carry these transactions, so no group
  re-sync is needed.
2026-09-08 02:01:29 +05:30
44 changed files with 1759 additions and 624 deletions

View File

@@ -8349,8 +8349,6 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8394,8 +8392,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5Label'
nullable: true
type: array
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8501,7 +8497,6 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:
@@ -20313,9 +20308,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- role:read
- tokenizer:
- ADMIN
- role:read
summary: Get users by role id
tags:
- users
@@ -24877,9 +24872,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:attach
- role:attach
summary: Create user role
tags:
- users
@@ -24929,9 +24926,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:detach
- role:detach
- tokenizer:
- ADMIN
- user:detach
- role:detach
summary: Delete user role
tags:
- users
@@ -24992,9 +24991,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user role
tags:
- users
@@ -25040,9 +25039,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:list
- tokenizer:
- ADMIN
- user:list
summary: List users v2
tags:
- users
@@ -25102,9 +25101,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:create
- user:attach
- role:attach
- tokenizer:
- ADMIN
- user:create
- user:attach
- role:attach
summary: Create user
tags:
- users
@@ -25148,9 +25151,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:delete
- tokenizer:
- ADMIN
- user:delete
summary: Delete user
tags:
- users
@@ -25205,9 +25208,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user by user id
tags:
- users
@@ -25261,9 +25264,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:update
- tokenizer:
- ADMIN
- user:update
summary: Update user v2
tags:
- users
@@ -25319,9 +25322,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:list
- tokenizer:
- ADMIN
- factor-password:list
summary: Get reset password token for a user
tags:
- users
@@ -25384,9 +25387,11 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- factor-password:create
- user:attach
- tokenizer:
- ADMIN
- factor-password:create
- user:attach
summary: Create or regenerate reset password token for a user
tags:
- users
@@ -25444,9 +25449,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- ADMIN
- user:read
- tokenizer:
- ADMIN
- user:read
summary: Get user roles
tags:
- users

View File

@@ -121,7 +121,7 @@ The pieces:
- **`ResourceDef`** — declares the resource, verb, audit category, how to extract the instance ID, and how to turn that ID into selectors. ID extractors live in [pkg/types/coretypes/extractor.go](/pkg/types/coretypes/extractor.go): `PathParam("id")`, `BodyJSONPath("data.id")`, `BodyJSONArray("ids")`, and `ResponseJSONPath("data.id")` for IDs only known after the handler runs (e.g. `create`).
- **`SecuritySchemes`** — advertises the required scope (`resource.Scope(verb)`, e.g. `serviceaccount:create`) in the OpenAPI spec.
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
For routes that link two resources, use `AttachDetachSiblingResourceDef` (both sides are authz-checked, e.g. attaching a role to a service account requires `attach` on **both** the service account and the role). If the target list is optional in the payload (e.g. `userRoles` at user creation), set `OptionalTargets`: when no target ids resolve, the attach is vacuous and the def is skipped entirely — without it, the empty-id contract would check collection-level access on the target. For parent-child routes (e.g. creating an API key under a service account), both sides are checked too, but with different verbs: declare a `BasicResourceDef` checking the child with `create`/`delete`, alongside an `AttachDetachParentChildResourceDef` checking the parent with `attach`/`detach` (within that def the child is only recorded for audit) — see the `/api/v1/service_accounts/{id}/keys` route in [pkg/apiserver/signozapiserver/serviceaccount.go](/pkg/apiserver/signozapiserver/serviceaccount.go).
Prefer `CheckResources` with a `ResourceDef` for anything resource-shaped. The older coarse gates `ViewAccess`/`EditAccess`/`AdminAccess` only check "does the caller hold one of these roles" and give up per-resource granularity; `OpenAccess` performs no authorization (authentication still applies); `CheckWithoutClaims` serves anonymous routes such as public dashboards.

View File

@@ -9610,10 +9610,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9662,10 +9658,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
* @type array,null
*/
labels: Querybuildertypesv5LabelDTO[] | null;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9761,7 +9753,6 @@ export enum RuletypesAlertTypeDTO {
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',

View File

@@ -0,0 +1,14 @@
.container {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
margin: 8px 0;
}
.optionsTrigger {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}

View File

@@ -0,0 +1,82 @@
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Settings } from '@signozhq/icons';
import FieldsSelector from 'components/FieldsSelector';
import Controls, { ControlsProps } from 'container/Controls';
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
import { DataSource } from 'types/common/queryBuilder';
import styles from './Controls.module.scss';
function TraceExplorerControls({
isLoading,
totalCount,
perPageOptions,
config,
showSizeChanger = true,
}: TraceExplorerControlsProps): JSX.Element | null {
const { t } = useTranslation(['trace']);
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
const {
pagination,
handleCountItemsPerPageChange,
handleNavigateNext,
handleNavigatePrevious,
} = useQueryPagination(totalCount, perPageOptions);
return (
<div className={styles.container}>
{config?.fieldsSelector && (
<>
<div
className={styles.optionsTrigger}
onClick={(): void => setIsFieldsSelectorOpen(true)}
>
{t('options_menu.options')}
<Settings size="md" />
</div>
<FieldsSelector
isOpen={isFieldsSelectorOpen}
title="Edit columns"
fields={config.fieldsSelector.value}
onFieldsChange={config.fieldsSelector.onFieldsChange}
onClose={(): void => setIsFieldsSelectorOpen(false)}
signal={DataSource.TRACES}
/>
</>
)}
<Controls
isLoading={isLoading}
totalCount={totalCount}
offset={pagination.offset}
countPerPage={pagination.limit}
perPageOptions={perPageOptions}
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
handleNavigateNext={handleNavigateNext}
handleNavigatePrevious={handleNavigatePrevious}
showSizeChanger={showSizeChanger}
/>
</div>
);
}
TraceExplorerControls.defaultProps = {
config: null,
};
type TraceExplorerControlsProps = Pick<
ControlsProps,
'isLoading' | 'totalCount' | 'perPageOptions'
> & {
config?: OptionsMenuConfig | null;
showSizeChanger?: boolean;
};
TraceExplorerControls.defaultProps = {
showSizeChanger: true,
};
export default memo(TraceExplorerControls);

View File

@@ -0,0 +1,168 @@
import { Link } from 'react-router-dom';
import type { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TracesTableRow } from '../TracesTable/getFieldColumn';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { formUrlParams } from 'container/TraceDetail/utils';
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { ILog } from 'types/api/logs/log';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
export function BlockLink({
children,
to,
openInNewTab,
}: {
children: React.ReactNode;
to: string;
openInNewTab: boolean;
}): any {
// Display block to make the whole cell clickable
return (
<Link
to={to}
style={{ display: 'block' }}
target={openInNewTab ? '_blank' : '_self'}
>
{children}
</Link>
);
}
export const transformDataWithDate = (
data: QueryDataV3[],
): Omit<ILog, 'timestamp'>[] =>
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
[];
export const getTraceLink = (record: Record<string, unknown>): string => {
function readId(value: unknown): string {
if (typeof value === 'string' || typeof value === 'number') {
return String(value);
}
return '';
}
const traceId = readId(record.traceID) || readId(record.trace_id);
const spanId = readId(record.spanID) || readId(record.span_id);
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
spanId,
levelUp: 0,
levelDown: 0,
})}`;
};
export const getListColumns = (
selectedColumns: TelemetryFieldKey[],
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string | number,
): ColumnsType<RowData> => {
const initialColumns: ColumnsType<RowData> = [
{
dataIndex: 'date',
key: 'date',
title: 'Timestamp',
width: 145,
render: (value, item): JSX.Element => {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
)
: formatTimezoneAdjustedTimestamp(
value / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
},
},
];
const columns: ColumnsType<RowData> =
selectedColumns.map((props) => {
const name = props?.name || (props as any)?.key;
const fieldContext = props?.fieldContext || (props as any)?.type;
return {
title: name,
dataIndex: name,
key: buildCompositeKey(name, fieldContext),
width: 145,
render: (value, item): JSX.Element => {
if (value === '') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>N/A</Typography>
</BlockLink>
);
}
if (
name === 'httpMethod' ||
name === 'responseStatusCode' ||
name === 'response_status_code' ||
name === 'http_method'
) {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Badge data-testid={name} color="sakura" variant="outline">
{value}
</Badge>
</BlockLink>
);
}
if (name === 'durationNano' || name === 'duration_nano') {
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>{getMs(value)}ms</Typography>
</BlockLink>
);
}
return (
<BlockLink to={getTraceLink(item)} openInNewTab={false}>
<Typography data-testid={name}>
<LineClampedText text={value} lines={3} />
</Typography>
</BlockLink>
);
},
responsive: ['md'],
};
}) || [];
return [...initialColumns, ...columns];
};
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
// positional ids; `timestamp` is lifted from the wrapping ListItem.
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
const list = data[0]?.list;
if (!list) {
return [];
}
return list.map((item) => {
const row = item.data as Record<string, unknown>;
return {
...row,
timestamp: item.timestamp,
id: row.span_id,
};
}) as TracesTableRow[];
};

View File

@@ -0,0 +1,19 @@
.loading-traces {
padding: 24px 0;
height: 240px;
display: flex;
justify-content: center;
align-items: flex-start;
.loading-traces-content {
display: flex;
align-items: flex-start;
flex-direction: column;
.loading-gif {
height: 72px;
margin-left: -24px;
}
}
}

View File

@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { Typography } from '@signozhq/ui/typography';
import { DataSource } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import './TraceLoading.styles.scss';
export function TracesLoading(): JSX.Element {
const { t } = useTranslation('common');
return (
<div className="loading-traces">
<div className="loading-traces-content">
<img className="loading-gif" src={loadingPlaneUrl} alt="wait-icon" />
<Typography>
{t('pending_data_placeholder', { dataSource: DataSource.TRACES })}
</Typography>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { generatePath, Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import { useTimezone } from 'providers/Timezone';
import {
DURATION_FIELD_NAMES,
STATUS_FIELD_NAMES,
TIMESTAMP_FIELD_NAMES,
TRACE_ID_FIELD_NAMES,
} from './constants';
import { stringifyCellValue } from './utils';
type FieldCellProps = {
name: string;
value: unknown;
};
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
if (TIMESTAMP_FIELD_NAMES.has(name)) {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
const text = String(formatted);
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
}
if (value === '' || value == null) {
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
}
const text = stringifyCellValue(value);
if (TRACE_ID_FIELD_NAMES.has(name)) {
return (
<Link
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
data-testid="trace-id"
onClick={(e): void => e.stopPropagation()}
>
{text}
</Link>
);
}
if (STATUS_FIELD_NAMES.has(name)) {
return (
<Badge data-testid={name} color="sakura" variant="outline">
{text}
</Badge>
);
}
if (DURATION_FIELD_NAMES.has(name)) {
return (
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
);
}
return (
<TanStackTable.Text data-testid={name} title={text}>
{text}
</TanStackTable.Text>
);
}
export default FieldCell;

View File

@@ -0,0 +1,26 @@
.tableWrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tracesTable {
--tanstack-table-row-height: 54px;
--tanstack-table-header-height: 54px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-right-override: 15px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-header-padding-left-override: 5px;
--tanstack-cell-header-padding-left-first-column: 15px;
--tanstack-plain-body-line-clamp: 1;
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-header-cell-bg: var(--l1-background-hover);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
}

View File

@@ -0,0 +1,116 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import TanStackTable from 'components/TanStackTableView';
import type {
CellTypographySize,
TableColumnDef,
} from 'components/TanStackTableView/types';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { TracesLoading } from '../TraceLoading/TraceLoading';
import APIError from 'types/api/error';
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from 'utils/basePath';
import type { TracesTableRow } from './getFieldColumn';
import styles from './TracesTable.module.scss';
export type TracesTableProps = {
data: TracesTableRow[];
columns: TableColumnDef<TracesTableRow>[];
columnStorageKey?: string;
respectColumnOrder?: boolean;
panelType: PanelTypeKeys;
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
getRowHref: (row: TracesTableRow) => string;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: APIError | Error | null;
isFilterApplied: boolean;
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
onColumnRemove?: (columnId: string) => void;
cellTypographySize?: CellTypographySize;
};
function TracesTable({
data,
columns,
columnStorageKey,
respectColumnOrder = false,
panelType,
getRowHref,
isLoading,
isFetching,
isError,
error,
isFilterApplied,
onColumnOrderChange,
onColumnRemove,
cellTypographySize = 'medium',
}: TracesTableProps): JSX.Element {
const history = useHistory();
const isDataAbsent =
!isLoading && !isFetching && !isError && data.length === 0;
const handleRowClick = useCallback(
(row: TracesTableRow): void => {
history.push(getRowHref(row));
},
[history, getRowHref],
);
const handleRowClickNewTab = useCallback(
(row: TracesTableRow): void => {
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
},
[getRowHref],
);
return (
<>
{isError && error && <ErrorInPlace error={error as APIError} />}
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
{isDataAbsent && !isFilterApplied && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataAbsent && isFilterApplied && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
)}
{!isError && data.length !== 0 && (
<div className={styles.tableWrapper}>
<TanStackTable<TracesTableRow>
data={data}
columns={columns}
className={styles.tracesTable}
columnStorageKey={columnStorageKey}
respectColumnOrder={respectColumnOrder}
isLoading={isFetching}
cellTypographySize={cellTypographySize}
onColumnOrderChange={onColumnOrderChange}
onColumnRemove={onColumnRemove}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
getRowTestId={(row): string => `traces-table-row-${row.id}`}
/>
</div>
)}
</>
);
}
TracesTable.defaultProps = {
columnStorageKey: undefined,
respectColumnOrder: false,
onColumnOrderChange: undefined,
onColumnRemove: undefined,
cellTypographySize: 'medium',
};
export default TracesTable;

View File

@@ -0,0 +1,18 @@
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
// camelCase and snake_case variants are listed because the API has shipped both.
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
export const STATUS_FIELD_NAMES = new Set([
'httpMethod',
'http_method',
'http.method',
'http.request.method',
'responseStatusCode',
'response_status_code',
'http.status_code',
'http.response.status_code',
]);
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);

View File

@@ -0,0 +1,26 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
export type TracesTableRow = { id: string } & Record<string, unknown>;
export function getFieldColumn(
field: TelemetryFieldKey,
): TableColumnDef<TracesTableRow> {
const { name, fieldContext, fieldDataType } = field;
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,
width: { min: 192 },
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
};
}

View File

@@ -0,0 +1,12 @@
export function stringifyCellValue(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return JSON.stringify(value);
}

View File

@@ -1,9 +1,6 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import {
getFieldColumn,
TracesTableRow,
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
import { getFieldColumn, TracesTableRow } from '../TracesTable/getFieldColumn';
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];

View File

@@ -0,0 +1,235 @@
/**
* AI Assistant page-action factories for the Traces Explorer.
*
* Mirrors the logs equivalents — each factory closes over live page
* state/callbacks so `execute()` always operates on the current query, and
* the page component instantiates them via `useMemo` + `usePageActions`.
*
* See `pages/LogsExplorer/aiActions.ts` for the rationale behind writing
* BOTH `filters.items` and `filter.expression` and then re-using the same
* URL parser shape via `redirectWithQueryBuilderData`.
*/
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import {
aiFilterToTagFilterItem,
FILTER_OP_ENUM,
FILTER_VALUE_DESCRIPTION,
FilterDeps,
replaceFirstQueryData,
} from 'container/AIAssistant/pageActions/builderQueryHelpers';
import {
ActionResult,
PageAction,
} from 'container/AIAssistant/pageActions/types';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
interface AIFilter {
key: string;
op: string;
value: string;
}
interface RunQueryParams {
filters: AIFilter[];
}
interface AddFilterParams {
key: string;
op: string;
value: string;
}
type TracesView = 'list' | 'timeseries' | 'table' | 'trace';
interface ChangeViewParams {
view: TracesView;
}
interface SaveViewParams {
name: string;
}
/**
* Replace all active span filters and navigate to the updated query URL
* (which makes the WHERE clause reflect the new filters and triggers a re-run).
*/
export function tracesRunQueryAction(
deps: FilterDeps,
): PageAction<RunQueryParams> {
return {
id: 'traces.runQuery',
description: 'Replace the active trace filters and re-run the query',
parameters: {
type: 'object',
properties: {
filters: {
type: 'array',
description: 'Replacement filter list',
items: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
},
},
required: ['filters'],
},
autoApply: true,
execute: async ({ filters }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const tagItems = filters.map(aiFilterToTagFilterItem);
const newFilters = { items: tagItems, op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return {
summary: `Query updated with ${filters.length} filter(s) and re-run.`,
};
},
getContext: (): Record<string, unknown> => ({
filters:
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
(f: TagFilterItem) => ({
key: f.key?.key,
op: f.op,
value: f.value,
}),
) ?? [],
}),
};
}
/**
* Append a single filter to the existing trace query and navigate to the
* updated URL.
*/
export function tracesAddFilterAction(
deps: FilterDeps,
): PageAction<AddFilterParams> {
return {
id: 'traces.addFilter',
description: 'Add a single filter to the current trace query and re-run',
parameters: {
type: 'object',
properties: {
key: {
type: 'string',
description: 'Attribute key, e.g. service.name, http.status_code',
},
op: {
type: 'string',
enum: [...FILTER_OP_ENUM],
},
value: {
type: 'string',
description: FILTER_VALUE_DESCRIPTION,
},
},
required: ['key', 'op', 'value'],
},
autoApply: true,
execute: async ({ key, op, value }): Promise<ActionResult> => {
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
const existing = baseQuery.filters?.items ?? [];
const newItem = aiFilterToTagFilterItem({ key, op, value });
const newFilters = { items: [...existing, newItem], op: 'AND' };
const updatedBuilderQuery: IBuilderQuery = {
...baseQuery,
filters: newFilters,
filter: convertFiltersToExpression(newFilters),
};
deps.handleSetQueryData(0, updatedBuilderQuery);
deps.redirectWithQueryBuilderData(
replaceFirstQueryData(deps.currentQuery, updatedBuilderQuery),
);
return { summary: `Filter added: ${key} ${op} "${value}". Query re-run.` };
},
};
}
/**
* Switch the traces explorer between list / timeseries / table / trace views.
*/
export function tracesChangeViewAction(deps: {
onChangeView: (view: TracesView) => void;
}): PageAction<ChangeViewParams> {
return {
id: 'traces.changeView',
description:
'Switch the Traces Explorer between list, timeseries, table, and trace views',
parameters: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['list', 'timeseries', 'table', 'trace'],
description: 'The panel view to switch to',
},
},
required: ['view'],
},
execute: async ({ view }): Promise<ActionResult> => {
deps.onChangeView(view);
return { summary: `Switched to the "${view}" view.` };
},
};
}
/**
* Save the current trace query as a named view (stub — wires to real API
* when available).
*/
export function tracesSaveViewAction(deps: {
onSaveView: (name: string) => Promise<void>;
}): PageAction<SaveViewParams> {
return {
id: 'traces.saveView',
description: 'Save the current trace query as a named view',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name for the saved view' },
},
required: ['name'],
},
execute: async ({ name }): Promise<ActionResult> => {
await deps.onSaveView(name);
return { summary: `View "${name}" saved.` };
},
};
}

View File

@@ -0,0 +1,132 @@
import {
ArrowUpToLine,
Atom,
Filter,
SquareMousePointer,
Terminal,
Binoculars,
} from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import './ToolbarActions.styles.scss';
interface LeftToolbarActionsProps {
items: any;
selectedView: string;
onChangeSelectedView: (view: ExplorerViews) => void;
showFilter: boolean;
handleFilterVisibilityChange: () => void;
}
const activeTab = 'active-tab';
export default function LeftToolbarActions({
items,
selectedView,
onChangeSelectedView,
showFilter,
handleFilterVisibilityChange,
}: LeftToolbarActionsProps): JSX.Element {
const { clickhouse, list, timeseries, table, trace } = items;
return (
<div className="left-toolbar">
{!showFilter && (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size={12} />
<ArrowUpToLine size={12} style={{ transform: 'rotate(90deg)' }} />
</Button>
</Tooltip>
)}
<div className="left-toolbar-query-actions">
{list?.show && (
<Tooltip title="List View">
<Button
disabled={list.disabled}
className={cx(
'list-view-tab',
'explorer-view-option',
selectedView === list.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(list.key)}
>
<SquareMousePointer size={14} data-testid="search-view" />
List View
</Button>
</Tooltip>
)}
{trace?.show && (
<Tooltip title="Trace View">
<Button
disabled={trace.disabled}
className={cx(
'trace-view-tab',
'explorer-view-option',
selectedView === trace.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(trace.key)}
>
<SquareMousePointer size={14} data-testid="trace-view" />
Trace View
</Button>
</Tooltip>
)}
{timeseries?.show && (
<Tooltip title="Time Series">
<Button
disabled={timeseries.disabled}
className={cx(
'timeseries-view-tab',
'explorer-view-option',
selectedView === timeseries.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(timeseries.key)}
>
<Atom size={14} data-testid="query-builder-view" />
Time Series
</Button>
</Tooltip>
)}
{clickhouse?.show && (
<Tooltip title="Clickhouse">
<Button
disabled={clickhouse.disabled}
className={cx(
'clickhouse-view-tab',
'explorer-view-option',
selectedView === clickhouse.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(clickhouse.key)}
>
<Terminal size={14} data-testid="clickhouse-view" />
Clickhouse
</Button>
</Tooltip>
)}
{table?.show && (
<Tooltip title="Table">
<Button
disabled={table.disabled}
className={cx(
'table-view-tab',
'explorer-view-option',
selectedView === table.key ? activeTab : '',
)}
onClick={(): void => onChangeSelectedView(table.key)}
>
<Binoculars size={14} data-testid="query-builder-view-v2" />
Table
</Button>
</Tooltip>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
.left-toolbar {
display: flex;
align-items: center;
.filter-btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
height: 32px;
margin-right: 12px;
border: 1px solid var(--l1-border);
}
.left-toolbar-query-actions {
display: flex;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
flex-direction: row;
border-bottom: none;
margin-bottom: -1px;
.prom-ql-icon {
height: 14px;
width: 14px;
}
.explorer-view-option {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
border: none;
padding: 9px;
box-shadow: none;
border-radius: 0px;
border-left: 1px solid var(--l1-border);
border-bottom: 1px solid var(--l1-border);
gap: 8px;
&.active-tab {
background-color: var(--primary-background);
border-bottom: 1px solid var(--primary-background);
color: var(--primary-foreground);
&:hover {
background-color: var(--primary-background) !important;
}
}
&:disabled {
background-color: var(--l3-background);
opacity: 0.6;
}
&:first-child {
border-left: 1px solid transparent;
}
&:hover {
background-color: transparent !important;
border-left: 1px solid transparent !important;
color: var(--l1-foreground);
}
}
}
.frequency-chart-view-controller {
display: flex;
align-items: center;
padding-left: 8px;
gap: 8px;
}
}
.right-toolbar {
display: flex;
align-items: center;
background-color: var(--bg-robin-600);
}
.right-actions {
display: flex;
align-items: center;
}
.loading-container {
display: flex;
gap: 8px;
align-items: center;
.loading-btn {
display: flex;
width: 32px;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex-shrink: 0;
border-radius: 2px;
background: var(--l3-background);
box-shadow: none;
border: none;
}
.cancel-run {
display: flex;
height: 33px;
padding: 4px 10px;
justify-content: center;
align-items: center;
gap: 6px;
flex: 1 0 0;
border-radius: 2px;
background: var(--danger-background);
border: none;
}
.cancel-run:hover {
background-color: var(--bg-cherry-400) !important;
color: var(--l1-foreground) !important;
}
}

View File

@@ -53,6 +53,7 @@ type provider struct {
authzService authz.AuthZ
orgHandler organization.Handler
userHandler user.Handler
userGetter user.Getter
sessionHandler session.Handler
authDomainHandler authdomain.Handler
authDomainModule authdomain.Module
@@ -97,6 +98,7 @@ func NewFactory(
authzService authz.AuthZ,
orgHandler organization.Handler,
userHandler user.Handler,
userGetter user.Getter,
sessionHandler session.Handler,
authDomainHandler authdomain.Handler,
authDomainModule authdomain.Module,
@@ -144,6 +146,7 @@ func NewFactory(
authzService,
orgHandler,
userHandler,
userGetter,
sessionHandler,
authDomainHandler,
authDomainModule,
@@ -193,6 +196,7 @@ func newProvider(
authzService authz.AuthZ,
orgHandler organization.Handler,
userHandler user.Handler,
userGetter user.Getter,
sessionHandler session.Handler,
authDomainHandler authdomain.Handler,
authDomainModule authdomain.Module,
@@ -240,6 +244,7 @@ func newProvider(
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
userGetter: userGetter,
authzService: authzService,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,

View File

@@ -6,24 +6,35 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (provider *provider) addUserRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.ListUsers, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},
Summary: "List users v2",
Description: "This endpoint lists all users for the organization",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
@@ -61,20 +72,43 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUser), handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUser",
Tags: []string{"users"},
Summary: "Create user",
Description: "This endpoint creates a user for the organization",
Request: new(authtypes.PostableUser),
RequestContentType: "application/json",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.ResponseJSONPath("data.id"),
Selector: coretypes.WildcardSelector,
},
handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: provider.roleSelector,
OptionalTargets: true,
},
),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -95,88 +129,148 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUser), handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUser",
Tags: []string{"users"},
Summary: "Get user by user id",
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserWithRoles),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.UpdateUser), handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.UpdateUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateUser",
Tags: []string{"users"},
Summary: "Update user v2",
Description: "This endpoint updates the user by id",
Request: new(types.UpdatableUser),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUser), handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUser, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUser",
Tags: []string{"users"},
Summary: "Delete user",
Description: "This endpoint deletes the user by id",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetResetPasswordToken), handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetResetPasswordToken",
Tags: []string{"users"},
Summary: "Get reset password token for a user",
Description: "This endpoint returns the existing reset password token for a user.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryAccessControl,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateResetPasswordToken), handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/reset_password_tokens", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateResetPasswordToken, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateResetPasswordToken",
Tags: []string{"users"},
Summary: "Create or regenerate reset password token for a user",
Description: "This endpoint creates or regenerates a reset password token for a user. If a valid token exists, it is returned. If expired, a new one is created.",
Request: nil,
RequestContentType: "",
Response: new(types.ResetPasswordToken),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceFactorPassword.Scope(coretypes.VerbCreate), coretypes.ResourceUser.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(
handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceFactorPassword,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
},
handler.AttachDetachParentChildResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
ParentResource: coretypes.ResourceUser,
ParentID: coretypes.PathParam("id"),
ParentSelector: coretypes.IDSelector,
ChildResource: coretypes.ResourceMetaResourceFactorPassword,
ChildIDs: coretypes.OneID(coretypes.PathParam("id")),
},
),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
@@ -248,90 +342,196 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetRolesByUserID), handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/users/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetRolesByUserID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetRolesByUserID",
Tags: []string{"users"},
Summary: "Get user roles",
Description: "This endpoint returns the user roles by user id",
Request: nil,
RequestContentType: "",
Response: make([]*authtypes.Role, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUsersByRoleID), handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/roles/{id}/users", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUsersByRoleID, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUsersByRoleID",
Tags: []string{"users"},
Summary: "Get users by role id",
Description: "This endpoint returns the users having the role by role id",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceRole.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceRole,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: coretypes.PathParam("id"),
Selector: provider.roleSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.CreateUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.BodyJSONPath("userId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.GetUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceUser,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.userRoleUserIDExtractor(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.userHandler.DeleteUserRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceUser.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
Verb: coretypes.VerbDetach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(provider.userRoleUserIDExtractor()),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(provider.userRoleRoleIDExtractor()),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
func (provider *provider) userRoleUserIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
if err != nil {
return "", err
}
return userRole.UserID.String(), nil
})
}
func (provider *provider) userRoleRoleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
userRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
userRole, err := provider.userGetter.GetUserRoleByOrgIDAndID(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), userRoleID)
if err != nil {
return "", err
}
return userRole.RoleID.String(), nil
})
}

View File

@@ -12,32 +12,25 @@ import (
// PrepareParamsForTracesV5 returns the traces explorer query params for the
// given range and filter; the traces explorer writes its time params in
// nanoseconds. queryType is builder_ai_query for the AI observability explorer.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string, queryType qbtypes.QueryType) url.Values {
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), whereClause)
// nanoseconds.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
}
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
// range and filter; the logs explorer writes its time params in milliseconds.
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged
builderQueryType := ""
if queryType != qbtypes.QueryTypeBuilder {
builderQueryType = queryType.StringValue()
}
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
urlData := URLShareableCompositeQuery{
QueryType: "builder",
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{{
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: whereClause},
DataSource: dataSource,
Filter: &FilterExpression{Expression: whereClause},
}},
QueryFormulas: make([]string, 0),
},
@@ -54,8 +47,7 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
// queries (builder_ai_query) count as trace builder queries.
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
switch signal {
@@ -71,7 +63,7 @@ func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal teleme
var q qbtypes.QueryBuilderQuery[T]
found := false
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
if query.Type != qbtypes.QueryTypeBuilder {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {

View File

@@ -30,15 +30,6 @@ func TestBuilderQueryForSignal(t *testing.T) {
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
@@ -55,14 +46,6 @@ func TestBuilderQueryForSignal(t *testing.T) {
assert.Empty(t, groupBy)
})
t.Run("ai trace query counts as traces", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "session.id", groupBy[0].Name)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)

View File

@@ -13,9 +13,8 @@ type FilterExpression struct {
// LinkQuery carries the only fields the explorer pages read from a shared
// link; the frontend fills in the rest of the query shape with defaults.
type LinkQuery struct {
DataSource string `json:"dataSource"`
BuilderQueryType string `json:"builderQueryType,omitempty"`
Filter *FilterExpression `json:"filter,omitempty"`
DataSource string `json:"dataSource"`
Filter *FilterExpression `json:"filter,omitempty"`
}
type URLShareableBuilderQuery struct {

View File

@@ -53,9 +53,20 @@ type AttachDetachSiblingResourceDef struct {
TargetResource coretypes.Resource
TargetIDs coretypes.ResourceIDsExtractor
TargetSelector coretypes.SelectorFunc
// OptionalTargets drops the def when no target ids resolve, for routes where
// the target list is legitimately optional in the payload.
OptionalTargets bool
}
func (def AttachDetachSiblingResourceDef) resolveRequest(ec coretypes.ExtractorContext) []coretypes.ResolvedResource {
if def.OptionalTargets && def.TargetIDs.IsPhase(coretypes.PhaseRequest) {
// extractors are pure; on error fall through and let fill record it
ids, err := def.TargetIDs.Fn(ec)
if err == nil && len(ids) == 0 {
return nil
}
}
return []coretypes.ResolvedResource{
coretypes.NewResolvedResourceWithTarget(
def.Verb,

View File

@@ -0,0 +1,68 @@
package handler
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func userRoleAttachDef(optionalTargets bool) AttachDetachSiblingResourceDef {
return AttachDetachSiblingResourceDef{
Verb: coretypes.VerbAttach,
Category: coretypes.ActionCategoryAccessControl,
SourceResource: coretypes.ResourceUser,
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
SourceSelector: coretypes.WildcardSelector,
TargetResource: coretypes.NewResourceRole(),
TargetIDs: coretypes.BodyJSONArray("userRoles.#.id"),
TargetSelector: coretypes.WildcardSelector,
OptionalTargets: optionalTargets,
}
}
func TestAttachDetachSiblingResourceDefOptionalTargets(t *testing.T) {
t.Run("absent target list resolves to no checks", func(t *testing.T) {
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"name":"jane"}`)}
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
assert.Empty(t, resolved)
})
t.Run("empty target list resolves to no checks", func(t *testing.T) {
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[]}`)}
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
assert.Empty(t, resolved)
})
t.Run("present targets resolve the attach as usual", func(t *testing.T) {
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[{"id":"role-a"},{"id":"role-b"}]}`)}
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
require.Len(t, resolved, 1)
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.NoError(t, resolved[0].Err())
assert.Equal(t, []string{"role-a", "role-b"}, withTarget.TargetIDs())
})
t.Run("malformed target entry still fails closed", func(t *testing.T) {
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"userRoles":[{"id":""}]}`)}
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(true)}, ec)
require.Len(t, resolved, 1)
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
})
t.Run("without the flag the empty-id contract is preserved", func(t *testing.T) {
ec := coretypes.ExtractorContext{RequestBody: []byte(`{"name":"jane"}`)}
resolved := ResolveRequest([]ResourceDef{userRoleAttachDef(false)}, ec)
require.Len(t, resolved, 1)
withTarget, ok := resolved[0].(coretypes.ResolvedResourceWithTargetResource)
require.True(t, ok)
assert.Equal(t, []string{""}, withTarget.TargetIDs())
})
}

View File

@@ -114,7 +114,6 @@ func (h *handler) GetRuleHistoryTimeline(w http.ResponseWriter, r *http.Request)
Fingerprint: item.Fingerprint,
Value: item.Value,
RelatedTracesLink: item.RelatedTracesLink,
RelatedAITracesLink: item.RelatedAITracesLink,
RelatedLogsLink: item.RelatedLogsLink,
})
}
@@ -152,12 +151,11 @@ func (h *handler) GetRuleHistoryContributors(w http.ResponseWriter, r *http.Requ
converted := make([]rulestatehistorytypes.GettableRuleStateHistoryContributor, 0, len(res))
for _, item := range res {
converted = append(converted, rulestatehistorytypes.GettableRuleStateHistoryContributor{
Fingerprint: item.Fingerprint,
Labels: item.Labels.ToQBLabels(),
Count: item.Count,
RelatedTracesLink: item.RelatedTracesLink,
RelatedAITracesLink: item.RelatedAITracesLink,
RelatedLogsLink: item.RelatedLogsLink,
Fingerprint: item.Fingerprint,
Labels: item.Labels.ToQBLabels(),
Count: item.Count,
RelatedTracesLink: item.RelatedTracesLink,
RelatedLogsLink: item.RelatedLogsLink,
})
}
render.Success(w, http.StatusOK, converted)

View File

@@ -41,8 +41,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
return nil
}
signal, ok := relatedLinkSignal(rule.AlertType)
if !ok {
if rule.AlertType != ruletypes.AlertTypeLogs && rule.AlertType != ruletypes.AlertTypeTraces {
return nil
}
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
@@ -63,6 +62,10 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
}
signal := telemetrytypes.SignalLogs
if rule.AlertType == ruletypes.AlertTypeTraces {
signal = telemetrytypes.SignalTraces
}
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
@@ -81,35 +84,21 @@ func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time)
return start.Add(-3 * time.Minute), end
}
// links returns the explorer query params for the given entry labels and time
// range.
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
// links returns the encoded logs and traces explorer query params for the
// given entry labels and time range; at most one of the two is non-empty.
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) (string, string) {
lbls := map[string]string{}
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
return rulestatehistorytypes.RelatedLinks{}
return "", ""
}
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
switch b.alertType {
case ruletypes.AlertTypeLogs:
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()}
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode(), ""
case ruletypes.AlertTypeTraces:
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilder).Encode()}
case ruletypes.AlertTypeAITraces:
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilderAI).Encode()}
return "", contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
}
return rulestatehistorytypes.RelatedLinks{}
}
// relatedLinkSignal returns the explorer signal that related links open for
// the alert type, or ok=false when the alert type has none (e.g. metrics).
func relatedLinkSignal(alertType ruletypes.AlertType) (telemetrytypes.Signal, bool) {
switch alertType {
case ruletypes.AlertTypeLogs:
return telemetrytypes.SignalLogs, true
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
return telemetrytypes.SignalTraces, true
}
return telemetrytypes.SignalUnspecified, false
return "", ""
}

View File

@@ -34,7 +34,7 @@ func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, rule
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
for idx := range items {
start, end := builder.queryWindow(items[idx].UnixMilli)
items[idx].RelatedLinks = builder.links(items[idx].Labels, start, end)
items[idx].RelatedLogsLink, items[idx].RelatedTracesLink = builder.links(items[idx].Labels, start, end)
}
}
@@ -60,7 +60,7 @@ func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID,
// span it too instead of a single evaluation window
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
for idx := range contributors {
contributors[idx].RelatedLinks = builder.links(contributors[idx].Labels, start, end)
contributors[idx].RelatedLogsLink, contributors[idx].RelatedTracesLink = builder.links(contributors[idx].Labels, start, end)
}
}

View File

@@ -830,12 +830,12 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
} else if rule.AlertType == ruletypes.AlertTypeTraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
@@ -849,7 +849,7 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
}
}
}

View File

@@ -420,7 +420,7 @@ func (r *BaseRule) ShouldSkipNewGroups() bool {
func (r *BaseRule) isFilterNewSeriesSupported() bool {
if r.ruleCondition.CompositeQuery.QueryType == ruletypes.QueryTypeBuilder {
for _, query := range r.ruleCondition.CompositeQuery.Queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
if query.Type != qbtypes.QueryTypeBuilder {
continue
}
switch query.Spec.(type) {

View File

@@ -7,7 +7,6 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -165,24 +164,6 @@ type filterNewSeriesTestCase struct {
expectError bool
}
func TestBaseRule_IsFilterNewSeriesSupported(t *testing.T) {
postableRule := createPostableRule(&ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{{Expression: "max(trace.total_tokens)"}},
},
}},
})
rule, err := NewBaseRule("test-rule", valuer.GenerateUUID(), &postableRule, mustParseURL(t, "http://localhost:8080"), WithLogger(instrumentationtest.New().Logger()))
require.NoError(t, err)
assert.False(t, rule.isFilterNewSeriesSupported())
}
func TestBaseRule_FilterNewSeries(t *testing.T) {
defaultEvalTime := time.Unix(1700000000, 0)
defaultNewGroupEvalDelay := valuer.MustParseTextDuration("2m")

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/statementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/aistatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/logsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/metricsstatementbuilder"
"github.com/SigNoz/signoz/pkg/statementbuilder/tracesstatementbuilder"
@@ -128,40 +127,3 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
0, // maxConcurrentQueries (0 means default)
)
}
func prepareQuerierForAITraces(t *testing.T, telemetryStore telemetrystore.TelemetryStore, keysMap map[string][]*telemetrytypes.TelemetryFieldKey) querier.Querier {
t.Helper()
providerSettings := instrumentationtest.New().ToProviderSettings()
metadataStore := telemetrytypestest.NewMockMetadataStore()
for _, keys := range keysMap {
for _, key := range keys {
key.Signal = telemetrytypes.SignalTraces
}
}
metadataStore.KeysMap = keysMap
fl := flaggertest.New(t)
aiTraceStmtBuilder, err := aistatementbuilder.NewFactory(telemetryStore, metadataStore, fl).New(context.Background(), providerSettings, statementbuilder.Config{})
require.NoError(t, err)
return querier.New(
providerSettings,
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
aiTraceStmtBuilder,
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
0,
0, // maxConcurrentQueries (0 means default)
)
}

View File

@@ -132,7 +132,7 @@ func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, groupBy)
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause, r.typ.BuilderQueryType())
return contextlinks.PrepareParamsForTracesV5(start, end, whereClause)
}
func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
@@ -308,14 +308,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
// is used alert grouping, and we want to group alerts with the same
// label set, but different timestamps, together.
switch r.typ {
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
case ruletypes.AlertTypeTraces:
params := r.prepareParamsForTraces(ctx, ts, smpl.Metric)
if len(params) > 0 {
explorerPath := "traces-explorer"
if r.typ == ruletypes.AlertTypeAITraces {
explorerPath = "ai-observability/explorer"
}
link := r.ExternalURL(explorerPath, params)
link := r.ExternalURL("traces-explorer", params)
r.logger.InfoContext(ctx, "adding traces link to annotations", slog.String("annotation.link", link))
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedTraces, Value: link})
}

View File

@@ -916,104 +916,6 @@ func TestThresholdRuleTracesLink(t *testing.T) {
}
}
func TestThresholdRuleAITracesLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "AI traces link test",
AlertType: ruletypes.AlertTypeAITraces,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: valuer.MustParseTextDuration("5m"),
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &ruletypes.AlertCompositeQuery{
QueryType: ruletypes.QueryTypeBuilder,
Queries: []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
StepInterval: qbtypes.Step{Duration: time.Minute},
Aggregations: []qbtypes.TraceAggregation{{
Expression: "count()",
}},
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{
Expression: "service.name = 'llm-gateway'",
},
},
}},
},
},
}
cols := make([]cmock.ColumnType, 0)
cols = append(cols, cmock.ColumnType{Name: "value", Type: "Float64"})
cols = append(cols, cmock.ColumnType{Name: "attr", Type: "String"})
cols = append(cols, cmock.ColumnType{Name: "timestamp", Type: "DateTime"})
keysMap := map[string][]*telemetrytypes.TelemetryFieldKey{
"service.name": {
{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
logger := instrumentationtest.New().Logger()
for idx, c := range testCases {
telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
rows := cmock.NewRows(cols, c.values)
telemetryStore.Mock().
ExpectQuery("SELECT any").
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil).
WillReturnRows(rows)
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
postableRule.RuleCondition.CompareOperator = c.compareOperator
postableRule.RuleCondition.MatchType = c.matchType
postableRule.RuleCondition.Target = &c.target
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
postableRule.RuleCondition.TargetUnit = c.targetUnit
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
CompareOperator: c.compareOperator,
},
},
}
postableRule.Annotations = map[string]string{
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
}
externalURL := mustParseURL(t, "http://localhost:8080")
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, querier, logger, externalURL)
require.NoError(t, err, "case %d", idx)
alertsFound, err := rule.Eval(context.Background(), time.Now())
require.NoError(t, err, "case %d", idx)
assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
for _, item := range rule.Active {
link := item.Annotations.Map()[ruletypes.AnnotationRelatedTraces]
assert.True(t, strings.HasPrefix(link, "http://localhost:8080/ai-observability/explorer?"), "case %d: %s", idx, link)
assert.Contains(t, link, "builder_ai_query", "case %d", idx)
assert.Contains(t, link, "llm-gateway", "case %d", idx)
}
}
}
func TestThresholdRuleLogsLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Logs link test",

View File

@@ -66,6 +66,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ authz.AuthZ }{},
struct{ organization.Handler }{},
struct{ user.Handler }{},
struct{ user.Getter }{},
struct{ session.Handler }{},
struct{ authdomain.Handler }{},
struct{ authdomain.Module }{},

View File

@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
sqlmigration.NewAddUserTuplesFactory(sqlstore),
)
}
@@ -326,6 +327,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
authz,
implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
modules.UserGetter,
implsession.NewHandler(modules.Session, globalConfig),
implauthdomain.NewHandler(modules.AuthDomain),
modules.AuthDomain,

View File

@@ -0,0 +1,144 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addUserTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddUserTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_user_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addUserTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addUserTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addUserTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// user and factor-password moved from the legacy AdminAccess role gate to
// CheckResources, which on enterprise requires real tuples -- existing orgs
// never had these written, only new orgs get them from the registry at
// bootstrap. The managed-role transaction groups stored per org already
// carry these transactions, so no re-sync is needed here.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "user", "user", "create"},
{authtypes.SigNozAdminRoleName, "user", "user", "read"},
{authtypes.SigNozAdminRoleName, "user", "user", "update"},
{authtypes.SigNozAdminRoleName, "user", "user", "delete"},
{authtypes.SigNozAdminRoleName, "user", "user", "list"},
{authtypes.SigNozAdminRoleName, "user", "user", "attach"},
{authtypes.SigNozAdminRoleName, "user", "user", "detach"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "create"},
{authtypes.SigNozAdminRoleName, "metaresource", "factor-password", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addUserTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -23,17 +23,15 @@ type GettableRuleStateHistory struct {
Fingerprint uint64 `json:"fingerprint" required:"true"`
Value float64 `json:"value" required:"true"`
RelatedTracesLink string `json:"relatedTracesLink,omitempty"`
RelatedAITracesLink string `json:"relatedAITracesLink,omitempty"`
RelatedLogsLink string `json:"relatedLogsLink,omitempty"`
}
type GettableRuleStateHistoryContributor struct {
Fingerprint uint64 `json:"fingerprint" required:"true"`
Labels []*qbtypes.Label `json:"labels" required:"true"`
Count uint64 `json:"count" required:"true"`
RelatedTracesLink string `json:"relatedTracesLink,omitempty"`
RelatedAITracesLink string `json:"relatedAITracesLink,omitempty"`
RelatedLogsLink string `json:"relatedLogsLink,omitempty"`
Fingerprint uint64 `json:"fingerprint" required:"true"`
Labels []*qbtypes.Label `json:"labels" required:"true"`
Count uint64 `json:"count" required:"true"`
RelatedTracesLink string `json:"relatedTracesLink,omitempty"`
RelatedLogsLink string `json:"relatedLogsLink,omitempty"`
}
type GettableRuleStateWindow struct {

View File

@@ -77,23 +77,16 @@ type RuleStateHistory struct {
Fingerprint uint64 `ch:"fingerprint"`
Value float64 `ch:"value"`
RelatedLinks
RelatedTracesLink string
RelatedLogsLink string
}
type RuleStateHistoryContributor struct {
Fingerprint uint64 `ch:"fingerprint"`
Labels LabelsString `ch:"labels"`
Count uint64 `ch:"count"`
RelatedLinks
}
// RelatedLinks holds the encoded explorer query params for a history entry;
// at most one field is non-empty.
type RelatedLinks struct {
RelatedTracesLink string
RelatedAITracesLink string
RelatedLogsLink string
Fingerprint uint64 `ch:"fingerprint"`
Labels LabelsString `ch:"labels"`
Count uint64 `ch:"count"`
RelatedTracesLink string
RelatedLogsLink string
}
type Store interface {

View File

@@ -24,7 +24,6 @@ const (
AlertTypeTraces AlertType = "TRACES_BASED_ALERT"
AlertTypeLogs AlertType = "LOGS_BASED_ALERT"
AlertTypeExceptions AlertType = "EXCEPTIONS_BASED_ALERT"
AlertTypeAITraces AlertType = "AI_TRACES_BASED_ALERT"
)
// Enum implements jsonschema.Enum; returns the acceptable values for AlertType.
@@ -34,19 +33,9 @@ func (AlertType) Enum() []any {
AlertTypeTraces,
AlertTypeLogs,
AlertTypeExceptions,
AlertTypeAITraces,
}
}
// BuilderQueryType returns the query type the alert type's builder queries
// carry; only AI trace alerts use builder_ai_query.
func (t AlertType) BuilderQueryType() qbtypes.QueryType {
if t == AlertTypeAITraces {
return qbtypes.QueryTypeBuilderAI
}
return qbtypes.QueryTypeBuilder
}
const (
DefaultSchemaVersion = "v1"
SchemaVersionV2Alpha1 = "v2alpha1"
@@ -417,11 +406,11 @@ func (r *PostableRule) Validate() error {
if r.AlertType != "" {
switch r.AlertType {
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces:
case AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions:
default:
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput,
"alertType: unsupported value %q; must be one of %q, %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions, AlertTypeAITraces))
"alertType: unsupported value %q; must be one of %q, %q, %q, %q",
r.AlertType, AlertTypeMetric, AlertTypeTraces, AlertTypeLogs, AlertTypeExceptions))
}
}

View File

@@ -209,10 +209,6 @@ func TestValidate_PostableRule_Common(t *testing.T) {
name: "valid alertType EXCEPTIONS_BASED_ALERT",
json: patchJSON(validV1Builder(), `{"alertType": "EXCEPTIONS_BASED_ALERT"}`),
},
{
name: "valid alertType AI_TRACES_BASED_ALERT",
json: patchJSON(validV1Builder(), `{"alertType": "AI_TRACES_BASED_ALERT"}`),
},
{
name: "empty alertType is ok (optional)",
json: removeField(validV1Builder(), "alertType"),

View File

@@ -1,16 +0,0 @@
{"timestamp": "2026-01-29T10:00:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f601", "span_id": "c1b2c3d4e5f6a701", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:00:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f601", "span_id": "d1b2c3d4e5f6a701", "parent_span_id": "c1b2c3d4e5f6a701", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 300, "gen_ai.usage.output_tokens": 120, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:00:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f602", "span_id": "c1b2c3d4e5f6a702", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:00:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f602", "span_id": "d1b2c3d4e5f6a702", "parent_span_id": "c1b2c3d4e5f6a702", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 310, "gen_ai.usage.output_tokens": 125, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:01:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f603", "span_id": "c1b2c3d4e5f6a703", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:01:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f603", "span_id": "d1b2c3d4e5f6a703", "parent_span_id": "c1b2c3d4e5f6a703", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 320, "gen_ai.usage.output_tokens": 130, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:01:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f604", "span_id": "c1b2c3d4e5f6a704", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:01:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f604", "span_id": "d1b2c3d4e5f6a704", "parent_span_id": "c1b2c3d4e5f6a704", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 330, "gen_ai.usage.output_tokens": 135, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:02:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f605", "span_id": "c1b2c3d4e5f6a705", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:02:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f605", "span_id": "d1b2c3d4e5f6a705", "parent_span_id": "c1b2c3d4e5f6a705", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 340, "gen_ai.usage.output_tokens": 140, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:02:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f606", "span_id": "c1b2c3d4e5f6a706", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:02:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f606", "span_id": "d1b2c3d4e5f6a706", "parent_span_id": "c1b2c3d4e5f6a706", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 350, "gen_ai.usage.output_tokens": 145, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:03:00.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f607", "span_id": "c1b2c3d4e5f6a707", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:03:00.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f607", "span_id": "d1b2c3d4e5f6a707", "parent_span_id": "c1b2c3d4e5f6a707", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-0", "gen_ai.usage.input_tokens": 360, "gen_ai.usage.output_tokens": 150, "_signoz.gen_ai.total_cost": 0.02}}
{"timestamp": "2026-01-29T10:03:30.000000Z", "duration": "PT2.5S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f608", "span_id": "c1b2c3d4e5f6a708", "parent_span_id": "", "name": "POST /chat", "kind": 2, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"http.request.method": "POST", "http.response.status_code": "200", "http.request.path": "/chat"}}
{"timestamp": "2026-01-29T10:03:30.200000Z", "duration": "PT2S", "trace_id": "7a1f6d3d6b0a1f9e8a71b2c3d4e5f608", "span_id": "d1b2c3d4e5f6a708", "parent_span_id": "c1b2c3d4e5f6a708", "name": "chat gpt-4o-mini", "kind": 3, "status_code": 1, "status_message": "", "resources": {"deployment.environment": "production", "service.name": "llm-gateway", "os.type": "linux", "host.name": "linux-000"}, "attributes": {"gen_ai.system": "openai", "gen_ai.request.model": "gpt-4o-mini", "gen_ai.user.id": "user-1", "gen_ai.usage.input_tokens": 370, "gen_ai.usage.output_tokens": 155, "_signoz.gen_ai.total_cost": 0.02}}

View File

@@ -1,73 +0,0 @@
{
"alert": "rule_state_history_ai_traces",
"ruleType": "threshold_rule",
"alertType": "AI_TRACES_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 0,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_ai_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {
"expression": "trace.input_tokens > 100"
},
"groupBy": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string"
}
],
"aggregations": [
{
"expression": "max(trace.total_tokens)"
}
]
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -38,7 +38,6 @@ def test_logs_rule_history_related_links(
assert labels_to_map(item["labels"]).get("service.name") == "payment-service"
assert item.get("relatedTracesLink", "") == ""
assert item.get("relatedAITracesLink", "") == ""
assert item.get("relatedLogsLink", "") != ""
# logs explorer links carry the time range in milliseconds, anchored to the
@@ -53,7 +52,6 @@ def test_logs_rule_history_related_links(
assert len(contributors) == 1
assert contributors[0]["count"] >= 1
assert contributors[0].get("relatedTracesLink", "") == ""
assert contributors[0].get("relatedAITracesLink", "") == ""
assert contributors[0].get("relatedLogsLink", "") != ""
# contributor counts aggregate the whole queried range, so their links span it
@@ -82,7 +80,6 @@ def test_traces_rule_history_related_links(
assert labels_to_map(item["labels"]).get("service.name") == "order-service"
assert item.get("relatedLogsLink", "") == ""
assert item.get("relatedAITracesLink", "") == ""
assert item.get("relatedTracesLink", "") != ""
# traces explorer links carry the time range in nanoseconds, anchored to the
@@ -97,7 +94,6 @@ def test_traces_rule_history_related_links(
assert len(contributors) == 1
assert contributors[0]["count"] >= 1
assert contributors[0].get("relatedLogsLink", "") == ""
assert contributors[0].get("relatedAITracesLink", "") == ""
assert contributors[0].get("relatedTracesLink", "") != ""
# contributor counts aggregate the whole queried range, so their links span it
@@ -105,49 +101,3 @@ def test_traces_rule_history_related_links(
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["http.request.path", "/order", "service.name", "order-service"])
def test_ai_traces_rule_history_related_links(
signoz: types.SigNoz,
create_alert_rule_with_channel: Callable[[str], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query_start_ms = int((datetime.now(tz=UTC) - timedelta(minutes=30)).timestamp() * 1000)
insert_alert_data(
[types.AlertData(type="traces", data_path="alerts/test_scenarios/rule_state_history_ai_traces/alert_data.jsonl")],
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
rule_id = create_alert_rule_with_channel("alerts/test_scenarios/rule_state_history_ai_traces/rule.json")
(item, query_end_ms) = wait_for_firing_timeline_entry(signoz, token, rule_id, query_start_ms)
assert labels_to_map(item["labels"]).get("service.name") == "llm-gateway"
assert item.get("relatedLogsLink", "") == ""
assert item.get("relatedTracesLink", "") == ""
assert item.get("relatedAITracesLink", "") != ""
# AI alert links follow the traces explorer shape: a nanosecond range
# anchored to the second-truncated entry timestamp
link = parse_related_link(item["relatedAITracesLink"])
assert link["end"] == (item["unixMilli"] // 1000) * 1_000_000_000
assert link["end"] - link["start"] == RELATED_LINK_WINDOW_SECONDS * 1_000_000_000
assert_related_link_query(link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
# the AI explorer only resolves trace.* fields for builder_ai_query
assert link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"
contributors = get_rule_history_top_contributors(signoz, token, rule_id, query_start_ms, query_end_ms)
contributors = [c for c in contributors if labels_to_map(c["labels"]).get("service.name") == "llm-gateway"]
assert len(contributors) == 1
assert contributors[0]["count"] >= 1
assert contributors[0].get("relatedLogsLink", "") == ""
assert contributors[0].get("relatedTracesLink", "") == ""
assert contributors[0].get("relatedAITracesLink", "") != ""
contributor_link = parse_related_link(contributors[0]["relatedAITracesLink"])
assert contributor_link["start"] == query_start_ms * 1_000_000
assert contributor_link["end"] == query_end_ms * 1_000_000
assert_related_link_query(contributor_link, "traces", ["trace.input_tokens", "100", "service.name", "llm-gateway"])
assert contributor_link["composite_query"]["builder"]["queryData"][0]["builderQueryType"] == "builder_ai_query"