mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-10 13:30:42 +01:00
Compare commits
7 Commits
issue_6021
...
feat/user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c9b4711e4 | ||
|
|
13c92c0010 | ||
|
|
2f19c4811c | ||
|
|
1168aff204 | ||
|
|
99dcd79979 | ||
|
|
bff855a05f | ||
|
|
337e62c775 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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']);
|
||||
@@ -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} />,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
235
frontend/src/container/LLMObservability/Explorer/aiActions.ts
Normal file
235
frontend/src/container/LLMObservability/Explorer/aiActions.ts
Normal 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.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
68
pkg/http/handler/resourcedef_test.go
Normal file
68
pkg/http/handler/resourcedef_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "", ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }{},
|
||||
|
||||
@@ -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,
|
||||
|
||||
144
pkg/sqlmigration/127_add_user_tuples.go
Normal file
144
pkg/sqlmigration/127_add_user_tuples.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user