Compare commits

..

1 Commits

Author SHA1 Message Date
Gaurav Tewari
8d0eda4a0d feat: new ui update 2026-08-17 10:56:46 +05:30
56 changed files with 513 additions and 1758 deletions

View File

@@ -8805,7 +8805,6 @@ components:
- metric
- log
- span
- trace
- resource
- attribute
- body
@@ -9116,158 +9115,6 @@ info:
version: ""
openapi: 3.0.3
paths:
/api/v1/ai_observability/fields/keys:
get:
deprecated: false
description: This endpoint returns the field keys the AI observability explorer
can filter on, including the computed per-trace aggregates
operationId: GetAIObservabilityFieldsKeys
parameters:
- in: query
name: searchText
schema:
type: string
- in: query
name: fieldContext
schema:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
- in: query
name: fieldDataType
schema:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: limit
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldKeys'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get AI observability field keys
tags:
- ai_observability
/api/v1/ai_observability/fields/values:
get:
deprecated: false
description: This endpoint returns the values the AI observability explorer
can filter a field key on
operationId: GetAIObservabilityFieldsValues
parameters:
- in: query
name: searchText
schema:
type: string
- in: query
name: fieldContext
schema:
$ref: '#/components/schemas/TelemetrytypesFieldContext'
- in: query
name: fieldDataType
schema:
$ref: '#/components/schemas/TelemetrytypesFieldDataType'
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: limit
schema:
type: integer
- in: query
name: name
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldValues'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get AI observability field values
tags:
- ai_observability
/api/v1/alerts:
get:
deprecated: false

View File

@@ -1,236 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useQuery } from 'react-query';
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetAIObservabilityFieldsKeys200,
GetAIObservabilityFieldsKeysParams,
GetAIObservabilityFieldsValues200,
GetAIObservabilityFieldsValuesParams,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType } from '../../../generatedAPIInstance';
/**
* This endpoint returns the field keys the AI observability explorer can filter on, including the computed per-trace aggregates
* @summary Get AI observability field keys
*/
export const getAIObservabilityFieldsKeys = (
params?: GetAIObservabilityFieldsKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAIObservabilityFieldsKeys200>({
url: `/api/v1/ai_observability/fields/keys`,
method: 'GET',
params,
signal,
});
};
export const getGetAIObservabilityFieldsKeysQueryKey = (
params?: GetAIObservabilityFieldsKeysParams,
) => {
return [
`/api/v1/ai_observability/fields/keys`,
...(params ? [params] : []),
] as const;
};
export const getGetAIObservabilityFieldsKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetAIObservabilityFieldsKeysQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
> = ({ signal }) => getAIObservabilityFieldsKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAIObservabilityFieldsKeysQueryResult = NonNullable<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>
>;
export type GetAIObservabilityFieldsKeysQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get AI observability field keys
*/
export function useGetAIObservabilityFieldsKeys<
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAIObservabilityFieldsKeysQueryOptions(
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get AI observability field keys
*/
export const invalidateGetAIObservabilityFieldsKeys = async (
queryClient: QueryClient,
params?: GetAIObservabilityFieldsKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAIObservabilityFieldsKeysQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns the values the AI observability explorer can filter a field key on
* @summary Get AI observability field values
*/
export const getAIObservabilityFieldsValues = (
params?: GetAIObservabilityFieldsValuesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAIObservabilityFieldsValues200>({
url: `/api/v1/ai_observability/fields/values`,
method: 'GET',
params,
signal,
});
};
export const getGetAIObservabilityFieldsValuesQueryKey = (
params?: GetAIObservabilityFieldsValuesParams,
) => {
return [
`/api/v1/ai_observability/fields/values`,
...(params ? [params] : []),
] as const;
};
export const getGetAIObservabilityFieldsValuesQueryOptions = <
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetAIObservabilityFieldsValuesQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
> = ({ signal }) => getAIObservabilityFieldsValues(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAIObservabilityFieldsValuesQueryResult = NonNullable<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>
>;
export type GetAIObservabilityFieldsValuesQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get AI observability field values
*/
export function useGetAIObservabilityFieldsValues<
TData = Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: GetAIObservabilityFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAIObservabilityFieldsValuesQueryOptions(
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get AI observability field values
*/
export const invalidateGetAIObservabilityFieldsValues = async (
queryClient: QueryClient,
params?: GetAIObservabilityFieldsValuesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAIObservabilityFieldsValuesQueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -3490,7 +3490,6 @@ export enum TelemetrytypesFieldContextDTO {
metric = 'metric',
log = 'log',
span = 'span',
trace = 'trace',
resource = 'resource',
attribute = 'attribute',
body = 'body',
@@ -10176,93 +10175,6 @@ export interface ZeustypesPostableProfileDTO {
where_did_you_discover_signoz: string;
}
export type GetAIObservabilityFieldsKeysParams = {
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @description undefined
*/
fieldContext?: TelemetrytypesFieldContextDTO;
/**
* @description undefined
*/
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
};
export type GetAIObservabilityFieldsKeys200 = {
data: TelemetrytypesGettableFieldKeysDTO;
/**
* @type string
*/
status: string;
};
export type GetAIObservabilityFieldsValuesParams = {
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @description undefined
*/
fieldContext?: TelemetrytypesFieldContextDTO;
/**
* @description undefined
*/
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type string
* @description undefined
*/
name?: string;
};
export type GetAIObservabilityFieldsValues200 = {
data: TelemetrytypesGettableFieldValuesDTO;
/**
* @type string
*/
status: string;
};
export type GetAlerts200 = {
/**
* @type array

View File

@@ -9,7 +9,6 @@ const fieldContextToSuggestionMap: Record<
[TelemetrytypesFieldContextDTO.span]: 'span',
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
// no maps for the following values on suggestion context
[TelemetrytypesFieldContextDTO.trace]: undefined,
[TelemetrytypesFieldContextDTO.body]: undefined,
[TelemetrytypesFieldContextDTO.metric]: undefined,
[TelemetrytypesFieldContextDTO.log]: undefined,

View File

@@ -8,6 +8,10 @@
color: var(--l3-foreground);
}
.stateText {
--typography-font-size: var(--font-size-xs);
}
.stateContent {
display: inline-flex;
align-items: center;
@@ -24,8 +28,8 @@
}
.stateCell {
padding: var(--spacing-4) var(--spacing-6) var(--spacing-4) var(--spacing-12);
font-size: var(--periscope-font-size-base);
padding: var(--spacing-3) var(--spacing-6) var(--spacing-3) var(--spacing-12);
font-size: var(--font-size-xs);
}
.addMapperCell {

View File

@@ -82,8 +82,8 @@ function GroupMappers({
data-testid={`mappers-error-${group.localId}`}
>
<span className={styles.stateContent}>
<TriangleAlert size={14} color="var(--danger-background)" />
<Typography.Text as="span" size="base">
<TriangleAlert size={12} color="var(--danger-background)" />
<Typography.Text as="span" className={styles.stateText}>
Failed to load mappings. Please try again.
</Typography.Text>
</span>
@@ -103,8 +103,8 @@ function GroupMappers({
data-testid={`mappers-empty-${group.localId}`}
>
<span className={styles.stateContent}>
<ArrowLeftRight size={14} />
<Typography.Text as="span" size="base" color="muted">
<ArrowLeftRight size={12} />
<Typography.Text as="span" className={styles.stateText} color="muted">
No mappings in this group yet.
</Typography.Text>
</span>
@@ -123,7 +123,7 @@ function GroupMappers({
variant="link"
color="primary"
size="sm"
prefix={<Plus size={14} />}
prefix={<Plus size={12} />}
onClick={(): void => onAddMapper(group.localId)}
testId={`add-mapper-${group.localId}`}
>

View File

@@ -44,7 +44,7 @@ function MapperActionsMenu({
aria-label="Mapping actions"
testId={`mapper-actions-${mapper.localId}`}
>
<EllipsisVertical size={16} />
<EllipsisVertical size={14} />
</Button>
</DropdownMenuSimple>
);

View File

@@ -4,11 +4,13 @@
}
}
// Rows sit one level below the group header, so they run at a smaller type
// scale and tighter vertical rhythm than the header banner above them.
.cell {
padding: var(--spacing-4) var(--spacing-6);
vertical-align: middle;
color: var(--l1-foreground);
font-size: var(--periscope-font-size-base);
font-size: var(--font-size-xs);
}
.targetCell {
@@ -21,6 +23,13 @@
.targetName {
min-width: 0;
--typography-font-size: var(--font-size-xs);
}
.rowSwitch {
--switch-width: 1.5rem;
--switch-height: 0.875rem;
--switch-thumb-size: 0.625rem;
}
.targetContextBadge {

View File

@@ -49,7 +49,7 @@ function MapperRow({
>
<td className={cx(styles.cell, styles.targetCell)}>
<Typography.Text
weight="semibold"
weight="medium"
truncate={1}
title={mapper.name}
className={styles.targetName}
@@ -60,7 +60,7 @@ function MapperRow({
<Badge
color={
mapper.fieldContext === SpantypesFieldContextDTO.resource
? 'amber'
? 'forest'
: 'robin'
}
variant="outline"
@@ -106,6 +106,7 @@ function MapperRow({
{canManage && (
<div className={styles.rowActions}>
<Switch
className={styles.rowSwitch}
value={mapper.enabled}
onChange={(checked): void => onToggle(mapper.localId, checked)}
testId={`mapper-enabled-${mapper.localId}`}

View File

@@ -53,6 +53,7 @@
gap: var(--spacing-3);
background: var(--l2-background);
border-radius: 0;
border-left: 2px solid var(--accent-primary);
padding: var(--spacing-3) var(--spacing-6);
color: var(--l3-foreground);
@@ -112,6 +113,7 @@
padding: var(--spacing-3) var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
border-left: 2px solid var(--accent-primary);
&:last-child {
border-bottom: 1px solid var(--l2-border);

View File

@@ -5,7 +5,7 @@ import { Switch } from '@signozhq/ui/switch';
import ConditionKeyList from './components/ConditionKeyList/ConditionKeyList';
import styles from './GroupFormDrawer.module.scss';
import { FieldContext, GroupDraft, MapperDraftMode } from '../../types';
import { GroupDraft, MapperDraftMode } from '../../types';
import { isGroupDraftValid } from '../../utils';
interface GroupFormDrawerProps {
@@ -91,7 +91,6 @@ function GroupFormDrawer({
placeholder="e.g. gen_ai."
addLabel="Add attribute key"
testIdPrefix="group-form-attribute"
fieldContext={FieldContext.attribute}
onChange={(attributes): void => setDraft({ ...draft, attributes })}
/>
@@ -102,7 +101,6 @@ function GroupFormDrawer({
placeholder="e.g. service.name"
addLabel="Add resource key"
testIdPrefix="group-form-resource"
fieldContext={FieldContext.resource}
onChange={(resource): void => setDraft({ ...draft, resource })}
/>

View File

@@ -18,15 +18,52 @@
letter-spacing: normal;
}
.keys {
.keyBox {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-6);
border-radius: 6px;
border: 1px solid var(--l2-border);
}
.keyRow {
.keyChips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-3);
}
.keyChip {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
font-family: var(--font-mono, monospace);
}
.keyChipText {
display: block;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.keyChipRemove {
display: inline-flex;
align-items: center;
padding: 0;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
&:hover {
color: var(--accent-cherry);
}
}
.keyAdd {
display: flex;
gap: var(--spacing-3);
}

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Plus, X } from '@signozhq/icons';
import { Input } from '@signozhq/ui/input';
import { X } from '@signozhq/icons';
import { FieldContextValue } from 'container/LLMObservability/AttributeMapping/types';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './ConditionKeyList.module.scss';
interface ConditionKeyListProps {
@@ -12,10 +13,11 @@ interface ConditionKeyListProps {
placeholder: string;
addLabel: string;
testIdPrefix: string;
fieldContext: FieldContextValue;
onChange: (keys: string[]) => void;
}
// Condition keys as removable chips. Chips are starred on both sides because
// the key is matched as a substring, not as a full key name.
function ConditionKeyList({
label,
labelHint,
@@ -23,19 +25,22 @@ function ConditionKeyList({
placeholder,
addLabel,
testIdPrefix,
fieldContext,
onChange,
}: ConditionKeyListProps): JSX.Element {
const updateKey = (index: number, value: string): void => {
onChange(keys.map((key, i) => (i === index ? value : key)));
};
const [keyInput, setKeyInput] = useState<string>('');
const addKey = (): void => {
onChange([...keys, '']);
const next = keyInput.trim();
if (!next || keys.includes(next)) {
setKeyInput('');
return;
}
onChange([...keys, next]);
setKeyInput('');
};
const removeKey = (index: number): void => {
onChange(keys.filter((_, i) => i !== index));
const removeKey = (key: string): void => {
onChange(keys.filter((existing) => existing !== key));
};
return (
@@ -45,43 +50,60 @@ function ConditionKeyList({
{labelHint && <span className={styles.labelHint}> {labelHint}</span>}
</span>
{keys.length > 0 && (
<div className={styles.keys}>
{keys.map((key, index) => (
// eslint-disable-next-line react/no-array-index-key
<div className={styles.keyRow} key={index}>
<KeySearchInput
className={styles.keyInput}
placeholder={placeholder}
value={key}
fieldContext={fieldContext}
onChange={(next): void => updateKey(index, next)}
testId={`${testIdPrefix}-${index}`}
/>
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Remove key"
onClick={(): void => removeKey(index)}
testId={`${testIdPrefix}-remove-${index}`}
<div className={styles.keyBox}>
{keys.length > 0 && (
<div className={styles.keyChips} data-testid={`${testIdPrefix}-chips`}>
{keys.map((key) => (
<Badge
key={key}
color="vanilla"
variant="outline"
className={styles.keyChip}
testId={`${testIdPrefix}-chip-${key}`}
>
<X size={14} />
</Button>
</div>
))}
</div>
)}
<span className={styles.keyChipText} title={key}>
{`*${key}*`}
</span>
<button
type="button"
aria-label={`Remove ${key}`}
className={styles.keyChipRemove}
onClick={(): void => removeKey(key)}
data-testid={`${testIdPrefix}-remove-${key}`}
>
<X size={10} />
</button>
</Badge>
))}
</div>
)}
<Button
variant="dashed"
color="secondary"
prefix={<Plus size={14} />}
onClick={addKey}
testId={`${testIdPrefix}-add`}
>
{addLabel}
</Button>
<div className={styles.keyAdd}>
<Input
className={styles.keyInput}
placeholder={placeholder}
value={keyInput}
autoComplete="off"
onChange={(event): void => setKeyInput(event.target.value)}
onKeyDown={(event): void => {
if (event.key === 'Enter') {
event.preventDefault();
addKey();
}
}}
testId={`${testIdPrefix}-input`}
/>
<Button
variant="outlined"
color="secondary"
aria-label={addLabel}
onClick={addKey}
testId={`${testIdPrefix}-add`}
>
+ Add
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
.keySearch {
position: relative;
flex: 1;
min-width: 0;
}
.keySearchDropdown {
position: absolute;
top: calc(100% + var(--spacing-2));
left: 0;
z-index: 20;
width: 100%;
max-height: 240px;
overflow-x: hidden;
overflow-y: auto;
padding: var(--spacing-2);
border: 1px solid var(--l2-border);
border-radius: 6px;
background: var(--l2-background);
box-shadow: 0 8px 24px
color-mix(in srgb, var(--bg-base-black) 40%, transparent);
}
.keySearchDropdownEmpty {
padding: var(--spacing-4) var(--spacing-5);
font-size: var(--font-size-xs);
color: var(--l3-foreground);
}
.keySearchOption {
display: block;
width: 100%;
max-width: 100%;
padding: var(--spacing-3) var(--spacing-5);
border: none;
border-radius: 3px;
background: transparent;
color: var(--l1-foreground);
font-size: var(--font-size-xs);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
&:hover {
background: var(--l2-background-hover);
}
}

View File

@@ -1,121 +0,0 @@
import { useMemo, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { useGetFieldsKeys } from 'api/generated/services/fields';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import useDebounce from 'hooks/useDebounce';
import {
FieldContext,
FieldContextValue,
} from 'container/LLMObservability/AttributeMapping/types';
import styles from './KeySearchInput.module.scss';
const SUGGESTION_LIMIT = 50;
const DEBOUNCE_MS = 300;
interface KeySearchInputProps {
value: string;
fieldContext: FieldContextValue;
placeholder?: string;
className?: string;
disabled?: boolean;
testId?: string;
onChange: (value: string) => void;
}
// Maps the mapper's attribute/resource context to the fields-endpoint context.
function toFieldsContext(
context: FieldContextValue,
): TelemetrytypesFieldContextDTO {
return context === FieldContext.resource
? TelemetrytypesFieldContextDTO.resource
: TelemetrytypesFieldContextDTO.attribute;
}
// Free-text input with span/resource key suggestions from /api/v1/fields/keys
// (signal=traces). Typing keeps the custom value; suggestions are assistive.
function KeySearchInput({
value,
fieldContext,
placeholder,
className,
disabled,
testId,
onChange,
}: KeySearchInputProps): JSX.Element {
const [isOpen, setIsOpen] = useState(false);
const debouncedSearch = useDebounce(value, DEBOUNCE_MS);
const { data, isFetching } = useGetFieldsKeys(
{
signal: TelemetrytypesSignalDTO.traces,
fieldContext: toFieldsContext(fieldContext),
searchText: debouncedSearch,
limit: SUGGESTION_LIMIT,
},
{ query: { enabled: isOpen && !disabled, keepPreviousData: true } },
);
const suggestions = useMemo(() => {
const keys = data?.data?.keys ?? {};
return Object.keys(keys)
.filter((key) => key !== value)
.slice(0, SUGGESTION_LIMIT);
}, [data, value]);
return (
<div className={cx(styles.keySearch, className)}>
<Input
placeholder={placeholder}
value={value}
disabled={disabled}
autoComplete="off"
onChange={(event): void => {
onChange(event.target.value);
setIsOpen(true);
}}
onFocus={(): void => setIsOpen(true)}
onBlur={(): void => setIsOpen(false)}
testId={testId}
/>
{isOpen && suggestions.length > 0 && (
<div
className={styles.keySearchDropdown}
data-testid={`${testId}-dropdown`}
>
{suggestions.map((name) => (
<button
type="button"
key={name}
className={styles.keySearchOption}
title={name}
// onMouseDown (not onClick) so selection runs before the input blur.
onMouseDown={(event): void => {
event.preventDefault();
onChange(name);
setIsOpen(false);
}}
data-testid={`${testId}-option-${name}`}
>
{name}
</button>
))}
</div>
)}
{isOpen && isFetching && suggestions.length === 0 && (
<div
className={cx(styles.keySearchDropdown, styles.keySearchDropdownEmpty)}
>
<Spinner size="small" height="auto" />
</div>
)}
</div>
);
}
export default KeySearchInput;

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import {
closestCenter,
@@ -19,7 +20,6 @@ import {
import { Plus, Trash2 } from '@signozhq/icons';
import { v4 as uuid } from 'uuid';
import KeySearchInput from '../KeySearchInput/KeySearchInput';
import styles from './MapperFormDrawer.module.scss';
import SourceAttributeRow from './components/SourceAttributeRow/SourceAttributeRow';
import {
@@ -164,12 +164,14 @@ function MapperFormDrawer({
<div className={styles.form}>
<div className={styles.field}>
<span className={styles.label}>Target attribute</span>
<KeySearchInput
<Input
placeholder="e.g. gen_ai.content.prompt"
value={draft.name}
fieldContext={draft.fieldContext}
disabled={isEdit}
onChange={(name): void => setDraft({ ...draft, name })}
autoComplete="off"
onChange={(event): void =>
setDraft({ ...draft, name: event.target.value })
}
testId="mapper-form-target"
/>
{isEdit && (

View File

@@ -1,4 +1,5 @@
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@@ -11,7 +12,6 @@ import {
MapperOperationValue,
SourceConfig,
} from 'container/LLMObservability/AttributeMapping/types';
import KeySearchInput from '../../../KeySearchInput/KeySearchInput';
import styles from './SourceAttributeRow.module.scss';
const CONTEXT_OPTIONS = [
@@ -66,12 +66,12 @@ function SourceAttributeRow({
<GripVertical size={14} />
</div>
<span className={styles.sourceIndex}>{index + 1}</span>
<KeySearchInput
<Input
className={styles.sourceInput}
placeholder="Source attribute key"
value={value.key}
fieldContext={value.context}
onChange={(key): void => onChange(index, { key })}
autoComplete="off"
onChange={(event): void => onChange(index, { key: event.target.value })}
testId={`mapper-form-source-${index}`}
/>
<SelectSimple

View File

@@ -123,7 +123,7 @@ export function buildUpdatableMapper(
export const EMPTY_GROUP_DRAFT: GroupDraft = {
id: null,
name: '',
attributes: [''],
attributes: [],
resource: [],
enabled: true,
};
@@ -170,8 +170,8 @@ export function buildDraftGroup(
localId: group.id,
serverId: group.id,
name: group.name,
attributes: group.condition?.attributes ?? [],
resource: group.condition?.resource ?? [],
attributes: cleanKeys(group.condition?.attributes ?? []),
resource: cleanKeys(group.condition?.resource ?? []),
enabled: group.enabled,
mappers: mappers.map(buildDraftMapper),
};
@@ -182,7 +182,7 @@ export function groupDraftFromNode(group: DraftGroup): GroupDraft {
return {
id: group.localId,
name: group.name,
attributes: group.attributes.length > 0 ? group.attributes : [''],
attributes: group.attributes,
resource: group.resource,
enabled: group.enabled,
};

View File

@@ -690,7 +690,7 @@ function Success(props: ISuccessProps): JSX.Element {
urlQuery.set('spanId', span?.span_id);
}
safeNavigate({ search: urlQuery.toString() }, { replace: true });
safeNavigate({ search: urlQuery.toString() });
},
[setSelectedSpan, urlQuery, safeNavigate],
);

View File

@@ -260,13 +260,11 @@ describe('Span Click User Flows', () => {
) as HTMLElement;
await user.click(spanElement);
// Verify URL was updated with spanId
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringContaining('spanId=span-1'),
},
{ replace: true },
);
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringContaining('spanId=span-1'),
});
});
it('clicking span duration visually selects the span', async () => {
@@ -432,13 +430,10 @@ describe('Span Click User Flows', () => {
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
expect(mockUrlQuery.get('spanId')).toBe('span-1');
expect(mockSafeNavigate).toHaveBeenCalledWith(
{
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
},
{ replace: true },
);
expect(mockSafeNavigate).toHaveBeenCalledWith({
search: expect.stringMatching(
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
),
});
});
});

View File

@@ -80,7 +80,7 @@ function TraceDetailsV3(): JSX.Element {
const handleSpanDetailsClose = useCallback((): void => {
urlQuery.delete('spanId');
safeNavigate({ search: urlQuery.toString() }, { replace: true });
safeNavigate({ search: urlQuery.toString() });
}, [urlQuery, safeNavigate]);
const handleFilteredSpansChange = useCallback(

View File

@@ -223,7 +223,9 @@ func TestEmailNotifyWithErrors(t *testing.T) {
}
c, err := loadEmailTestConfiguration(cfgFile)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
title string
@@ -286,6 +288,11 @@ func TestEmailNotifyWithErrors(t *testing.T) {
},
} {
t.Run(tc.title, func(t *testing.T) {
if len(tc.errMsg) == 0 {
t.Fatal("please define the expected error message")
return
}
emailCfg := &config.EmailConfig{
Smarthost: c.Smarthost,
To: emailTo,
@@ -302,15 +309,15 @@ func TestEmailNotifyWithErrors(t *testing.T) {
_, retry, err := notifyEmail(t, emailCfg, c.Server)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errMsg)
assert.False(t, retry)
require.Contains(t, err.Error(), tc.errMsg)
require.False(t, retry)
e, err := c.Server.getLastEmail(t)
require.NoError(t, err)
if tc.hasEmail {
assert.NotNil(t, e)
require.NotNil(t, e)
} else {
assert.Nil(t, e)
require.Nil(t, e)
}
})
}
@@ -324,7 +331,9 @@ func TestEmailNotifyWithDoneContext(t *testing.T) {
}
c, err := loadEmailTestConfiguration(cfgFile)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
@@ -341,7 +350,7 @@ func TestEmailNotifyWithDoneContext(t *testing.T) {
c.Server,
)
require.Error(t, err)
assert.Contains(t, err.Error(), "establish connection to server")
require.Contains(t, err.Error(), "establish connection to server")
}
// TestEmailNotifyWithoutAuthentication sends an email to an instance of
@@ -354,7 +363,9 @@ func TestEmailNotifyWithoutAuthentication(t *testing.T) {
}
c, err := loadEmailTestConfiguration(cfgFile)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
mail, _, err := notifyEmail(
t,
@@ -379,7 +390,7 @@ func TestEmailNotifyWithoutAuthentication(t *testing.T) {
}
headers = append(headers, k)
}
assert.True(t, foundMsgID, "Couldn't find 'message-id' in %v", headers)
require.True(t, foundMsgID, "Couldn't find 'message-id' in %v", headers)
}
// TestEmailNotifyWithSTARTTLS connects to the server, upgrades the connection
@@ -395,7 +406,9 @@ func TestEmailNotifyWithSTARTTLS(t *testing.T) {
}
c, err := loadEmailTestConfiguration(cfgFile)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
trueVar := true
_, _, err = notifyEmail(
@@ -424,7 +437,9 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
}
c, err := loadEmailTestConfiguration(cfgFile)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
td := t.TempDir()
fileWithCorrectPassword, err := os.CreateTemp(td, "smtp-password-correct")
@@ -568,13 +583,13 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
e, retry, err := notifyEmail(t, emailCfg, c.Server)
if len(tc.errMsg) > 0 {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errMsg)
assert.Equal(t, tc.retry, retry)
require.Contains(t, err.Error(), tc.errMsg)
require.Equal(t, tc.retry, retry)
return
}
require.NoError(t, err)
assert.Equal(t, "1 firing alert(s)", e.Subject)
require.Equal(t, "1 firing alert(s)", e.Subject)
getAddresses := func(addresses []map[string]string) []string {
res := make([]string, 0, len(addresses))
@@ -585,21 +600,19 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
}
to := getAddresses(e.To)
from := getAddresses(e.From)
assert.Equal(t, strings.Split(emailCfg.To, ","), to)
assert.Equal(t, strings.Split(emailCfg.From, ","), from)
require.Equal(t, strings.Split(emailCfg.To, ","), to)
require.Equal(t, strings.Split(emailCfg.From, ","), from)
if len(emailCfg.HTML) > 0 {
require.NotNil(t, e.HTML)
assert.Equal(t, emailCfg.HTML, *e.HTML)
require.Equal(t, emailCfg.HTML, *e.HTML)
} else {
assert.Nil(t, e.HTML)
require.Nil(t, e.HTML)
}
if len(emailCfg.Text) > 0 {
require.NotNil(t, e.Text)
assert.Equal(t, emailCfg.Text, *e.Text)
require.Equal(t, emailCfg.Text, *e.Text)
} else {
assert.Nil(t, e.Text)
require.Nil(t, e.Text)
}
})
}
@@ -611,7 +624,7 @@ func TestEmailConfigNoAuthMechs(t *testing.T) {
}
_, err := email.auth("")
require.Error(t, err)
assert.Equal(t, "unknown auth mechanism: ", err.Error())
require.Equal(t, "unknown auth mechanism: ", err.Error())
}
func TestEmailConfigMissingAuthParam(t *testing.T) {
@@ -621,19 +634,19 @@ func TestEmailConfigMissingAuthParam(t *testing.T) {
}
_, err := email.auth("CRAM-MD5")
require.Error(t, err)
assert.Equal(t, "missing secret for CRAM-MD5 auth mechanism", err.Error())
require.Equal(t, "missing secret for CRAM-MD5 auth mechanism", err.Error())
_, err = email.auth("PLAIN")
require.Error(t, err)
assert.Equal(t, "missing password for PLAIN auth mechanism", err.Error())
require.Equal(t, "missing password for PLAIN auth mechanism", err.Error())
_, err = email.auth("LOGIN")
require.Error(t, err)
assert.Equal(t, "missing password for LOGIN auth mechanism", err.Error())
require.Equal(t, "missing password for LOGIN auth mechanism", err.Error())
_, err = email.auth("PLAIN LOGIN")
require.Error(t, err)
assert.Equal(t, "missing password for PLAIN auth mechanism\nmissing password for LOGIN auth mechanism", err.Error())
require.Equal(t, "missing password for PLAIN auth mechanism\nmissing password for LOGIN auth mechanism", err.Error())
}
func TestEmailNoUsernameCustomError(t *testing.T) {
@@ -642,7 +655,7 @@ func TestEmailNoUsernameCustomError(t *testing.T) {
}
a, err := email.auth("CRAM-MD5")
require.ErrorIs(t, err, errNoAuthUsernameConfigured)
assert.Nil(t, a)
require.Nil(t, a)
}
// TestEmailRejected simulates the failure of an otherwise valid message submission which fails at a later point than
@@ -707,7 +720,7 @@ func TestEmailRejected(t *testing.T) {
// Send the alert to mock SMTP server.
retry, err := e.Notify(context.Background(), firingAlert)
require.ErrorContains(t, err, "501 5.5.4 Rejected!")
assert.True(t, retry)
require.True(t, retry)
require.NoError(t, srv.Shutdown(ctx))
require.Eventuallyf(t, func() bool {
@@ -776,7 +789,9 @@ func TestEmailNotifyWithThreading(t *testing.T) {
}
c, err := loadEmailTestConfiguration(cfgFile)
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
@@ -821,22 +836,22 @@ func TestEmailNotifyWithThreading(t *testing.T) {
referencesValue := mail.Headers["references"]
inReplyToValue := mail.Headers["in-reply-to"]
assert.NotEmpty(t, referencesValue, "References header not found in %v", mail.Headers)
assert.NotEmpty(t, inReplyToValue, "In-Reply-To header not found in %v", mail.Headers)
require.NotEmpty(t, referencesValue, "References header not found in %v", mail.Headers)
require.NotEmpty(t, inReplyToValue, "In-Reply-To header not found in %v", mail.Headers)
assert.Equal(t, referencesValue, inReplyToValue, "References and In-Reply-To should match")
require.Equal(t, referencesValue, inReplyToValue, "References and In-Reply-To should match")
// Verify the format: <alert-HASH-DATE@alertmanager>
assert.Contains(t, referencesValue, "<alert-")
assert.Contains(t, referencesValue, "@alertmanager>")
require.Contains(t, referencesValue, "<alert-")
require.Contains(t, referencesValue, "@alertmanager>")
if tc.wantDatePart {
today := time.Now().Format("2006-01-02")
assert.Contains(t, referencesValue, today, "threading header should contain today's date")
require.Contains(t, referencesValue, today, "threading header should contain today's date")
} else {
// With thread_by_date: none, there should be no date
// (empty string between hash and @).
assert.Contains(t, referencesValue, "-@alertmanager>", "threading header should have empty date part")
require.Contains(t, referencesValue, "-@alertmanager>", "threading header should have empty date part")
}
})
}
@@ -889,14 +904,14 @@ func TestEmailGetPassword(t *testing.T) {
require.Error(t, err)
if errors.Asc(err, errors.CodeInternal) {
_, _, errMsg, _, _, _ := errors.Unwrapb(err)
assert.Contains(t, errMsg, tc.errMsg)
require.Contains(t, errMsg, tc.errMsg)
} else {
assert.Contains(t, err.Error(), tc.errMsg)
require.Contains(t, err.Error(), tc.errMsg)
}
assert.Empty(t, password)
require.Empty(t, password)
} else {
require.NoError(t, err)
assert.Equal(t, "secret", password)
require.Equal(t, "secret", password)
}
})
}
@@ -947,11 +962,11 @@ func TestEmailGetSecret(t *testing.T) {
secret, err := email.getAuthSecret()
if len(tc.errMsg) > 0 {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errMsg)
assert.Empty(t, secret)
require.Contains(t, err.Error(), tc.errMsg)
require.Empty(t, secret)
} else {
require.NoError(t, err)
assert.Equal(t, "secret", secret)
require.Equal(t, "secret", secret)
}
})
}
@@ -1017,7 +1032,7 @@ func TestEmailImplicitTLS(t *testing.T) {
useImplicitTLS = cfg.Smarthost.Port == "465"
}
assert.Equal(t, tt.expectImplicit, useImplicitTLS,
require.Equal(t, tt.expectImplicit, useImplicitTLS,
"Expected useImplicitTLS=%v for port=%s with forceImplicitTLS=%v",
tt.expectImplicit, tt.port, tt.forceImplicitTLS)
})
@@ -1059,8 +1074,8 @@ func TestPrepareContent(t *testing.T) {
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
assert.Equal(t, "subj", subject)
assert.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
require.Equal(t, "subj", subject)
require.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
})
t.Run("custom title template; default body HTML template", func(t *testing.T) {
@@ -1088,8 +1103,8 @@ func TestPrepareContent(t *testing.T) {
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
assert.Equal(t, "Status: firing", htmlBody)
assert.Equal(t, "fixed from firing", subject)
require.Equal(t, "Status: firing", htmlBody)
require.Equal(t, "fixed from firing", subject)
})
t.Run("default template without HTML", func(t *testing.T) {
@@ -1110,8 +1125,8 @@ func TestPrepareContent(t *testing.T) {
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
assert.Equal(t, "", htmlBody)
assert.Equal(t, "the email subject", subject)
require.Equal(t, "", htmlBody)
require.Equal(t, "the email subject", subject)
})
t.Run("custom title template; custom body template", func(t *testing.T) {
@@ -1145,11 +1160,11 @@ func TestPrepareContent(t *testing.T) {
ctx := context.Background()
subject, htmlBody, err := n.prepareContent(ctx, alerts)
require.NoError(t, err)
assert.Contains(t, htmlBody, "<!DOCTYPE html>")
assert.Contains(t, htmlBody, "<p>line two</p>")
assert.NotContains(t, htmlBody, "Well, what are you?")
assert.Equal(t, "fixed from firing", subject)
assert.NotContains(t, subject, "subject")
require.Contains(t, htmlBody, "<!DOCTYPE html>")
require.Contains(t, htmlBody, "<p>line two</p>")
require.NotContains(t, htmlBody, "Well, what are you?")
require.Equal(t, subject, "fixed from firing")
require.NotContains(t, subject, "subject")
})
}

View File

@@ -22,7 +22,6 @@ import (
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
@@ -55,7 +54,7 @@ func TestMSTeamsV2Retry(t *testing.T) {
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
actual, _ := notifier.retrier.Check(statusCode, nil)
assert.Equal(t, expected, actual, "retry - error on status %d", statusCode)
require.Equal(t, expected, actual, "retry - error on status %d", statusCode)
}
}
@@ -111,7 +110,7 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
} else {
var reasonError *notify.ErrorWithReason
require.ErrorAs(t, err, &reasonError)
assert.Equal(t, tt.expectedReason, reasonError.Reason)
require.Equal(t, tt.expectedReason, reasonError.Reason)
}
})
}
@@ -134,6 +133,7 @@ func TestMSTeamsV2Templating(t *testing.T) {
cfg *config.MSTeamsV2Config
titleLink string
retry bool
errMsg string
}{
{
@@ -143,6 +143,7 @@ func TestMSTeamsV2Templating(t *testing.T) {
Text: `{{ template "msteams.default.text" . }}`,
},
titleLink: `{{ template "msteamsv2.default.titleLink" . }}`,
retry: false,
},
{
title: "title with templating errors",
@@ -184,12 +185,12 @@ func TestMSTeamsV2Templating(t *testing.T) {
},
}...)
if tc.errMsg == "" {
assert.NoError(t, err)
require.NoError(t, err)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errMsg)
require.Contains(t, err.Error(), tc.errMsg)
}
assert.False(t, ok)
require.Equal(t, tc.retry, ok)
})
}
}
@@ -249,14 +250,14 @@ func TestPrepareContent(t *testing.T) {
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.Len(t, blocks, 2)
require.NotEmpty(t, blocks)
// First block should be the title with color (firing = red)
assert.Equal(t, "Bolder", blocks[0].Weight)
assert.Equal(t, colorRed, blocks[0].Color)
require.Equal(t, "Bolder", blocks[0].Weight)
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
assert.Equal(t, "Alertname: test", blocks[0].Text)
require.Equal(t, "Alertname: test", blocks[0].Text)
// verify body text
assert.Equal(t, "Firing alert: test", blocks[1].Text)
require.Equal(t, "Firing alert: test", blocks[1].Text)
})
t.Run("custom template - per-alert color", func(t *testing.T) {
@@ -304,15 +305,16 @@ func TestPrepareContent(t *testing.T) {
}
blocks, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
require.NotEmpty(t, blocks)
// total 3 blocks: title and 2 body blocks
require.Len(t, blocks, 3)
require.True(t, len(blocks) == 3)
// First block: title color is overall color of the alerts
assert.Equal(t, colorRed, blocks[0].Color)
require.Equal(t, colorRed, blocks[0].Color)
// verify title text
assert.Equal(t, "Custom Title", blocks[0].Text)
require.Equal(t, "Custom Title", blocks[0].Text)
// Body blocks should have per-alert color
assert.Equal(t, colorRed, blocks[1].Color) // firing
assert.Equal(t, colorGreen, blocks[2].Color) // resolved
require.Equal(t, colorRed, blocks[1].Color) // firing
require.Equal(t, colorGreen, blocks[2].Color) // resolved
})
}

View File

@@ -21,7 +21,6 @@ import (
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
@@ -50,7 +49,7 @@ func TestOpsGenieRetry(t *testing.T) {
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
for statusCode, expected := range test.RetryTests(retryCodes) {
actual, _ := notifier.retrier.Check(statusCode, nil)
assert.Equal(t, expected, actual, "error on status %d", statusCode)
require.Equal(t, expected, actual, "error on status %d", statusCode)
}
}
@@ -104,7 +103,9 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
func TestOpsGenie(t *testing.T) {
u, err := url.Parse("https://opsgenie/api")
require.NoError(t, err)
if err != nil {
t.Fatalf("failed to parse URL: %v", err)
}
logger := promslog.NewNopLogger()
tmpl := test.CreateTmpl(t)
@@ -235,10 +236,10 @@ func TestOpsGenie(t *testing.T) {
req, retry, err := notifier.createRequests(ctx, alert1)
require.NoError(t, err)
require.Len(t, req, 1)
assert.True(t, retry)
assert.Equal(t, expectedURL, req[0].URL)
assert.Equal(t, "GenieKey http://am", req[0].Header.Get("Authorization"))
assert.Equal(t, tc.expectedEmptyAlertBody, readBody(t, req[0]))
require.True(t, retry)
require.Equal(t, expectedURL, req[0].URL)
require.Equal(t, "GenieKey http://am", req[0].Header.Get("Authorization"))
require.Equal(t, tc.expectedEmptyAlertBody, readBody(t, req[0]))
// Fully defined alert.
alert2 := &types.Alert{
@@ -265,15 +266,15 @@ func TestOpsGenie(t *testing.T) {
}
req, retry, err = notifier.createRequests(ctx, alert2)
require.NoError(t, err)
assert.True(t, retry)
require.True(t, retry)
require.Len(t, req, 1)
assert.Equal(t, tc.expectedBody, readBody(t, req[0]))
require.Equal(t, tc.expectedBody, readBody(t, req[0]))
// Broken API Key Template.
tc.cfg.APIKey = "{{ kaput "
_, _, err = notifier.createRequests(ctx, alert2)
require.Error(t, err)
assert.Equal(t, "template: :1: function \"kaput\" not defined", err.Error())
require.Equal(t, "template: :1: function \"kaput\" not defined", err.Error())
})
}
}
@@ -306,7 +307,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
require.NoError(t, err)
requests, retry, err := notifierWithUpdate.createRequests(ctx, alert)
require.NoError(t, err)
assert.True(t, retry)
require.True(t, retry)
require.Len(t, requests, 3)
body0 := readBody(t, requests[0])
@@ -315,13 +316,13 @@ func TestOpsGenieWithUpdate(t *testing.T) {
key, _ := notify.ExtractGroupKey(ctx)
alias := key.Hash()
assert.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String())
assert.NotEmpty(t, body0)
require.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String())
require.NotEmpty(t, body0)
assert.Equal(t, requests[1].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/message?identifierType=alias", alias))
assert.JSONEq(t, `{"message":"new message"}`, body1)
assert.Equal(t, requests[2].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/description?identifierType=alias", alias))
assert.JSONEq(t, `{"description":"new description"}`, body2)
require.Equal(t, requests[1].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/message?identifierType=alias", alias))
require.JSONEq(t, `{"message":"new message"}`, body1)
require.Equal(t, requests[2].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/description?identifierType=alias", alias))
require.JSONEq(t, `{"description":"new description"}`, body2)
}
func TestOpsGenieApiKeyFile(t *testing.T) {
@@ -340,8 +341,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
require.NoError(t, err)
require.Len(t, requests, 1)
assert.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
}
func TestPrepareContent(t *testing.T) {
@@ -377,8 +377,8 @@ func TestPrepareContent(t *testing.T) {
title, desc, prepErr := notifier.prepareContent(ctx, alerts)
require.NoError(t, prepErr)
assert.Equal(t, "Firing alert: test", title)
assert.Equal(t, "Check runbook for more details", desc)
require.Equal(t, "Firing alert: test", title)
require.Equal(t, "Check runbook for more details", desc)
})
t.Run("custom template", func(t *testing.T) {
@@ -431,9 +431,9 @@ func TestPrepareContent(t *testing.T) {
title, desc, err := notifier.prepareContent(ctx, alerts)
require.NoError(t, err)
assert.Equal(t, "High request throughput for payment", title)
require.Equal(t, "High request throughput for payment", title)
// Each alert body wrapped in <div>, separated by <hr>
assert.Equal(t, "<div><p>Alert firing in NS: potter-the-harry</p>\n</div><hr><div><p>Alert firing in NS: smart-the-rat</p>\n</div>", desc)
require.Equal(t, "<div><p>Alert firing in NS: potter-the-harry</p>\n</div><hr><div><p>Alert firing in NS: smart-the-rat</p>\n</div>", desc)
})
}

View File

@@ -25,7 +25,6 @@ import (
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
@@ -55,7 +54,7 @@ func TestPagerDutyRetryV1(t *testing.T) {
retryCodes := append(test.DefaultRetryCodes(), http.StatusForbidden)
for statusCode, expected := range test.RetryTests(retryCodes) {
actual, _ := notifier.retrier.Check(statusCode, nil)
assert.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode)
require.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode)
}
}
@@ -75,7 +74,7 @@ func TestPagerDutyRetryV2(t *testing.T) {
retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests)
for statusCode, expected := range test.RetryTests(retryCodes) {
actual, _ := notifier.retrier.Check(statusCode, nil)
assert.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode)
require.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode)
}
}
@@ -350,12 +349,12 @@ func TestPagerDutyTemplating(t *testing.T) {
require.Error(t, err)
if errors.Asc(err, errors.CodeInternal) {
_, _, errMsg, _, _, _ := errors.Unwrapb(err)
assert.Contains(t, errMsg, tc.errMsg)
require.Contains(t, errMsg, tc.errMsg)
} else {
assert.Contains(t, err.Error(), tc.errMsg)
require.Contains(t, err.Error(), tc.errMsg)
}
}
assert.Equal(t, tc.retry, ok)
require.Equal(t, tc.retry, ok)
})
}
}
@@ -394,7 +393,7 @@ func TestErrDetails(t *testing.T) {
} {
t.Run("", func(t *testing.T) {
err := errDetails(tc.status, tc.body)
assert.Contains(t, err, tc.exp)
require.Contains(t, err, tc.exp)
})
}
}
@@ -428,7 +427,7 @@ func TestEventSizeEnforcement(t *testing.T) {
encodedV1, err := notifierV1.encodeMessage(context.Background(), msgV1)
require.NoError(t, err)
assert.Contains(t, encodedV1.String(), `"details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
require.Contains(t, encodedV1.String(), `"details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
// V2 Messages
msgV2 := &pagerDutyMessage{
@@ -452,7 +451,7 @@ func TestEventSizeEnforcement(t *testing.T) {
encodedV2, err := notifierV2.encodeMessage(context.Background(), msgV2)
require.NoError(t, err)
assert.Contains(t, encodedV2.String(), `"custom_details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
require.Contains(t, encodedV2.String(), `"custom_details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
}
func TestPagerDutyEmptySrcHref(t *testing.T) {
@@ -544,9 +543,8 @@ func TestPagerDutyEmptySrcHref(t *testing.T) {
}
}
// Handler runs on the server's goroutine — require is illegal here.
assert.Equal(t, expectedImages, event.Images)
assert.Equal(t, expectedLinks, event.Links)
require.Equal(t, expectedImages, event.Images)
require.Equal(t, expectedLinks, event.Links)
},
))
defer server.Close()
@@ -646,7 +644,7 @@ func TestPagerDutyTimeout(t *testing.T) {
},
}
_, err = pd.Notify(ctx, alert)
assert.Equal(t, tt.wantErr, err != nil)
require.Equal(t, tt.wantErr, err != nil)
})
}
}
@@ -901,12 +899,11 @@ func TestRenderDetails(t *testing.T) {
tmpl: test.CreateTmpl(t),
}
got, err := n.renderDetails(tt.args.data)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
if (err != nil) != tt.wantErr {
t.Errorf("renderDetails() error = %v, wantErr %v", err, tt.wantErr)
return
}
assert.Equal(t, tt.want, got)
require.Equal(t, tt.want, got)
})
}
}
@@ -947,7 +944,7 @@ func TestPrepareContent(t *testing.T) {
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
assert.Equal(t, "HighCPU for Payment service (FIRING)", title)
require.Equal(t, "HighCPU for Payment service (FIRING)", title)
})
t.Run("custom template uses $variable annotation for title", func(t *testing.T) {
@@ -983,6 +980,6 @@ func TestPrepareContent(t *testing.T) {
title, err := notifier.prepareTitle(ctx, alerts)
require.NoError(t, err)
assert.Equal(t, "HighCPU on api-server is in resolved state", title)
require.Equal(t, "HighCPU on api-server is in resolved state", title)
})
}

View File

@@ -23,7 +23,6 @@ import (
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/prometheus/alertmanager/config"
@@ -51,7 +50,7 @@ func TestSlackRetry(t *testing.T) {
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
actual, _ := notifier.retrier.Check(statusCode, nil)
assert.Equal(t, expected, actual, "error on status %d", statusCode)
require.Equal(t, expected, actual, "error on status %d", statusCode)
}
}
@@ -233,15 +232,15 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
},
}
retry, err := notifier.Notify(ctx, alert1)
assert.Equal(t, tt.expectedRetry, retry)
require.Equal(t, tt.expectedRetry, retry)
if tt.noError {
require.NoError(t, err)
} else {
var reasonError *notify.ErrorWithReason
require.ErrorAs(t, err, &reasonError)
assert.Equal(t, tt.expectedReason, reasonError.Reason)
assert.Contains(t, err.Error(), tt.expectedErr)
assert.Contains(t, err.Error(), "channelname")
require.Equal(t, tt.expectedReason, reasonError.Reason)
require.Contains(t, err.Error(), tt.expectedErr)
require.Contains(t, err.Error(), "channelname")
}
})
}
@@ -297,7 +296,7 @@ func TestSlackTimeout(t *testing.T) {
},
}
_, err = notifier.Notify(ctx, alert)
assert.Equal(t, tt.wantErr, err != nil)
require.Equal(t, tt.wantErr, err != nil)
})
}
}
@@ -351,14 +350,14 @@ func TestPrepareContent(t *testing.T) {
require.NoError(t, err)
require.Len(t, atts, 1)
assert.Equal(t, "HighCPU (FIRING)", atts[0].Title)
assert.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
require.Equal(t, "HighCPU (FIRING)", atts[0].Title)
require.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
// Color is templated — firing alert should be "danger"
assert.Equal(t, "danger", atts[0].Color)
require.Equal(t, "danger", atts[0].Color)
// No BlockKit blocks for default template
assert.Nil(t, atts[0].Blocks)
require.Nil(t, atts[0].Blocks)
// Default markdownIn when config has none
assert.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
require.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
})
t.Run("custom template produces 1+N attachments with per-alert color", func(t *testing.T) {
@@ -429,10 +428,10 @@ func TestPrepareContent(t *testing.T) {
require.Len(t, atts, 3)
// First attachment: title-only, no color, no blocks
assert.Equal(t, "[firing] HighCPU — api-server", atts[0].Title)
assert.Empty(t, atts[0].Color)
assert.Nil(t, atts[0].Blocks)
assert.Equal(t, "https://alertmanager.signoz.com", atts[0].TitleLink)
require.Equal(t, "[firing] HighCPU — api-server", atts[0].Title)
require.Empty(t, atts[0].Color)
require.Nil(t, atts[0].Blocks)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].TitleLink)
expectedFiringBody := "*HighCPU*\n\n" +
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
@@ -447,16 +446,16 @@ func TestPrepareContent(t *testing.T) {
"*Status:* resolved | *Severity:* critical\n\n"
// Second attachment: firing alert body rendered as slack mrkdwn text, red color
assert.Nil(t, atts[1].Blocks)
assert.Equal(t, "#FF0000", atts[1].Color)
assert.Equal(t, []string{"text"}, atts[1].MrkdwnIn)
assert.Equal(t, expectedFiringBody, atts[1].Text)
require.Nil(t, atts[1].Blocks)
require.Equal(t, "#FF0000", atts[1].Color)
require.Equal(t, []string{"text"}, atts[1].MrkdwnIn)
require.Equal(t, expectedFiringBody, atts[1].Text)
// Third attachment: resolved alert body rendered as slack mrkdwn text, green color
assert.Nil(t, atts[2].Blocks)
assert.Equal(t, "#00FF00", atts[2].Color)
assert.Equal(t, []string{"text"}, atts[2].MrkdwnIn)
assert.Equal(t, expectedResolvedBody, atts[2].Text)
require.Nil(t, atts[2].Blocks)
require.Equal(t, "#00FF00", atts[2].Color)
require.Equal(t, []string{"text"}, atts[2].MrkdwnIn)
require.Equal(t, expectedResolvedBody, atts[2].Text)
})
t.Run("default template with fields and actions", func(t *testing.T) {
@@ -499,45 +498,49 @@ func TestPrepareContent(t *testing.T) {
// prepareContent does not populate fields/actions — that's done by
// addFieldsAndActions which is called from Notify.
assert.Nil(t, atts[0].Fields)
assert.Nil(t, atts[0].Actions)
require.Nil(t, atts[0].Fields)
require.Nil(t, atts[0].Actions)
// Simulate what Notify does after prepareContent
notifier.addFieldsAndActions(&atts[0], tmplText)
// Verify fields
require.Len(t, atts[0].Fields, 2)
assert.Equal(t, "Severity", atts[0].Fields[0].Title)
assert.Equal(t, "critical", atts[0].Fields[0].Value)
require.NotNil(t, atts[0].Fields[0].Short)
assert.True(t, *atts[0].Fields[0].Short)
assert.Equal(t, "Service", atts[0].Fields[1].Title)
assert.Equal(t, "api-server", atts[0].Fields[1].Value)
require.Equal(t, "Severity", atts[0].Fields[0].Title)
require.Equal(t, "critical", atts[0].Fields[0].Value)
require.True(t, *atts[0].Fields[0].Short)
require.Equal(t, "Service", atts[0].Fields[1].Title)
require.Equal(t, "api-server", atts[0].Fields[1].Value)
// Verify actions
require.Len(t, atts[0].Actions, 1)
assert.Equal(t, "button", atts[0].Actions[0].Type)
assert.Equal(t, "View Alert", atts[0].Actions[0].Text)
assert.Equal(t, "https://alertmanager.signoz.com", atts[0].Actions[0].URL)
require.Equal(t, "button", atts[0].Actions[0].Type)
require.Equal(t, "View Alert", atts[0].Actions[0].Text)
require.Equal(t, "https://alertmanager.signoz.com", atts[0].Actions[0].URL)
})
}
func TestSlackMessageField(t *testing.T) {
// 1. Setup a fake Slack server. The handler runs on the server's
// goroutine, so only assert (never require) is safe here.
// 1. Setup a fake Slack server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
// 2. VERIFY: Top-level text exists
assert.Equal(t, "My Top Level Message", body["text"])
if body["text"] != "My Top Level Message" {
t.Errorf("Expected top-level 'text' to be 'My Top Level Message', got %v", body["text"])
}
// 3. VERIFY: Old attachments still exist
attachments, ok := body["attachments"].([]any)
if assert.True(t, ok, "expected attachments to exist") && assert.NotEmpty(t, attachments) {
first, ok := attachments[0].(map[string]any)
if assert.True(t, ok, "expected attachment to be an object") {
assert.Equal(t, "Old Attachment Title", first["title"])
if !ok || len(attachments) == 0 {
t.Errorf("Expected attachments to exist")
} else {
first := attachments[0].(map[string]any)
if first["title"] != "Old Attachment Title" {
t.Errorf("Expected attachment title 'Old Attachment Title', got %v", first["title"])
}
}
@@ -558,16 +561,21 @@ func TestSlackMessageField(t *testing.T) {
}
tmpl, err := template.FromGlobs([]string{})
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
tmpl.ExternalURL = u
logger := slog.New(slog.DiscardHandler)
notifier, err := New(conf, tmpl, logger, newTestTemplater(tmpl))
require.NoError(t, err)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "test-group-key")
_, err = notifier.Notify(ctx)
require.NoError(t, err, "Notify failed")
if _, err := notifier.Notify(ctx); err != nil {
t.Fatal("Notify failed:", err)
}
}

View File

@@ -19,7 +19,6 @@ import (
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
@@ -40,12 +39,14 @@ func TestWebhookRetry(t *testing.T) {
promslog.NewNopLogger(),
alertmanagertemplate.New(tmpl, slog.Default()),
)
require.NoError(t, err)
if err != nil {
require.NoError(t, err)
}
t.Run("test retry status code", func(t *testing.T) {
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
actual, _ := notifier.retrier.Check(statusCode, nil)
assert.Equal(t, expected, actual, "error on status %d", statusCode)
require.Equal(t, expected, actual, "error on status %d", statusCode)
}
})
@@ -72,8 +73,7 @@ func TestWebhookRetry(t *testing.T) {
} {
t.Run("", func(t *testing.T) {
_, err = notifier.retrier.Check(tc.status, tc.body)
require.Error(t, err)
assert.Equal(t, tc.exp, err.Error())
require.Equal(t, tc.exp, err.Error())
})
}
})
@@ -83,16 +83,16 @@ func TestWebhookTruncateAlerts(t *testing.T) {
alerts := make([]*types.Alert, 10)
truncatedAlerts, numTruncated := truncateAlerts(0, alerts)
assert.Len(t, truncatedAlerts, 10)
assert.EqualValues(t, 0, numTruncated)
require.Len(t, truncatedAlerts, 10)
require.EqualValues(t, 0, numTruncated)
truncatedAlerts, numTruncated = truncateAlerts(4, alerts)
assert.Len(t, truncatedAlerts, 4)
assert.EqualValues(t, 6, numTruncated)
require.Len(t, truncatedAlerts, 4)
require.EqualValues(t, 6, numTruncated)
truncatedAlerts, numTruncated = truncateAlerts(100, alerts)
assert.Len(t, truncatedAlerts, 10)
assert.EqualValues(t, 0, numTruncated)
require.Len(t, truncatedAlerts, 10)
require.EqualValues(t, 0, numTruncated)
}
func TestWebhookRedactedURL(t *testing.T) {
@@ -219,10 +219,10 @@ func TestWebhookURLTemplating(t *testing.T) {
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
require.Contains(t, err.Error(), tc.expectedErrMsg)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expectedPath, calledURL)
require.Equal(t, tc.expectedPath, calledURL)
}
})
}

View File

@@ -1,51 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/gorilla/mux"
)
func (provider *provider) addAIObservabilityRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/ai_observability/fields/keys", handler.New(provider.authzMiddleware.ViewAccess(provider.aiObservabilityHandler.GetFieldsKeys), handler.OpenAPIDef{
ID: "GetAIObservabilityFieldsKeys",
Tags: []string{"ai_observability"},
Summary: "Get AI observability field keys",
Description: "This endpoint returns the field keys the AI observability explorer can filter on, including the computed per-trace aggregates",
Request: nil,
RequestQuery: new(aiobservabilitytypes.PostableFieldKeysParams),
RequestContentType: "",
Response: new(telemetrytypes.GettableFieldKeys),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/ai_observability/fields/values", handler.New(provider.authzMiddleware.ViewAccess(provider.aiObservabilityHandler.GetFieldsValues), handler.OpenAPIDef{
ID: "GetAIObservabilityFieldsValues",
Tags: []string{"ai_observability"},
Summary: "Get AI observability field values",
Description: "This endpoint returns the values the AI observability explorer can filter a field key on",
Request: nil,
RequestQuery: new(aiobservabilitytypes.PostableFieldValueParams),
RequestContentType: "",
Response: new(telemetrytypes.GettableFieldValues),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -62,7 +61,6 @@ type provider struct {
infraMonitoringHandler inframonitoring.Handler
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
aiObservabilityHandler aiobservability.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
@@ -99,7 +97,6 @@ func NewFactory(
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
aiObservabilityHandler aiobservability.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
@@ -139,7 +136,6 @@ func NewFactory(
infraMonitoringHandler,
gatewayHandler,
fieldsHandler,
aiObservabilityHandler,
authzHandler,
rawDataExportHandler,
zeusHandler,
@@ -181,7 +177,6 @@ func newProvider(
infraMonitoringHandler inframonitoring.Handler,
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
aiObservabilityHandler aiobservability.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
@@ -222,7 +217,6 @@ func newProvider(
infraMonitoringHandler: infraMonitoringHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
aiObservabilityHandler: aiObservabilityHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
@@ -319,10 +313,6 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addAIObservabilityRoutes(router); err != nil {
return err
}
if err := provider.addRawDataExportRoutes(router); err != nil {
return err
}

View File

@@ -1,11 +0,0 @@
package aiobservability
import "net/http"
type Handler interface {
// Gets the fields keys the AI observability explorer can filter on
GetFieldsKeys(http.ResponseWriter, *http.Request)
// Gets the values the AI observability explorer can filter a field key on
GetFieldsValues(http.ResponseWriter, *http.Request)
}

View File

@@ -1,105 +0,0 @@
package implaiobservability
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/telemetryschema/aitelemetryschema"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
telemetryMetadataStore telemetrytypes.MetadataStore
}
func NewHandler(telemetryMetadataStore telemetrytypes.MetadataStore) aiobservability.Handler {
return &handler{
telemetryMetadataStore: telemetryMetadataStore,
}
}
func (handler *handler) GetFieldsKeys(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
var params aiobservabilitytypes.PostableFieldKeysParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
fieldKeySelector := aiobservabilitytypes.NewFieldKeySelectorFromPostableFieldKeysParams(params)
keys := make(map[string][]*telemetrytypes.TelemetryFieldKey)
complete := true
// the trace context names the computed per-trace aggregates, which no scan can serve
if fieldKeySelector.FieldContext != telemetrytypes.FieldContextTrace {
keys, complete, err = handler.telemetryMetadataStore.GetKeys(ctx, orgID, fieldKeySelector)
if err != nil {
render.Error(rw, err)
return
}
}
render.Success(rw, http.StatusOK, &telemetrytypes.GettableFieldKeys{
Keys: aitelemetryschema.FieldKeys(keys, fieldKeySelector),
Complete: complete,
})
}
func (handler *handler) GetFieldsValues(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 10*time.Second)
defer cancel()
// binding ignores query params the struct does not declare, so an unsupported
// existingQuery would silently return values it did not narrow
if req.URL.Query().Has("existingQuery") {
render.Error(rw, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "existingQuery is not supported"))
return
}
var params aiobservabilitytypes.PostableFieldValueParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
fieldValueSelector := aiobservabilitytypes.NewFieldValueSelectorFromPostableFieldValueParams(params)
values := &telemetrytypes.TelemetryFieldValues{}
complete := true
// the trace context names the computed per-trace aggregates, which are never ingested
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextTrace {
values, complete, err = handler.telemetryMetadataStore.GetAllValues(ctx, valuer.MustNewUUID(claims.OrgID), fieldValueSelector)
if err != nil {
render.Error(rw, err)
return
}
}
render.Success(rw, http.StatusOK, &telemetrytypes.GettableFieldValues{
Values: values,
Complete: complete,
})
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
@@ -214,18 +213,18 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", aiobservabilitytypes.GenAIRequestModel)},
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", telemetrytypes.GenAIRequestModel)},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()", Alias: "spanCount"},
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: aiobservabilitytypes.GenAIRequestModel,
Name: telemetrytypes.GenAIRequestModel,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: aiobservabilitytypes.GenAIProviderName,
Name: telemetrytypes.GenAIProviderName,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
@@ -255,9 +254,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
switch c.Type {
case qbtypes.ColumnTypeGroup:
switch c.Name {
case aiobservabilitytypes.GenAIRequestModel:
case telemetrytypes.GenAIRequestModel:
modelIdx = i
case aiobservabilitytypes.GenAIProviderName:
case telemetrytypes.GenAIProviderName:
providerIdx = i
}
case qbtypes.ColumnTypeAggregation:

View File

@@ -23,7 +23,11 @@ func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, e
traces uint64
tracesLastSeenAt time.Time
)
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), max(timestamp) FROM %s", tracesTable)).Scan(&traces, &tracesLastSeenAt); err == nil {
tracesLastSeenExpr := "max(timestamp)"
if q.hasColumn(ctx, tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName, "inserted_at") {
tracesLastSeenExpr = "max(inserted_at)"
}
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), %s FROM %s", tracesLastSeenExpr, tracesTable)).Scan(&traces, &tracesLastSeenAt); err == nil {
stats["telemetry.traces.count"] = traces
if tracesLastSeenAt.Unix() != 0 {
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
@@ -37,7 +41,11 @@ func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, e
logs uint64
logsLastSeenAt time.Time
)
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), fromUnixTimestamp64Nano(max(timestamp)) FROM %s", logsTable)).Scan(&logs, &logsLastSeenAt); err == nil {
logsLastSeenExpr := "fromUnixTimestamp64Nano(max(timestamp))"
if q.hasColumn(ctx, logstelemetryschema.DBName, logstelemetryschema.LogsV2TableName, "inserted_at") {
logsLastSeenExpr = "max(inserted_at)"
}
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), %s FROM %s", logsLastSeenExpr, logsTable)).Scan(&logs, &logsLastSeenAt); err == nil {
stats["telemetry.logs.count"] = logs
if logsLastSeenAt.Unix() != 0 {
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
@@ -51,7 +59,11 @@ func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, e
metrics uint64
metricsLastSeenAt time.Time
)
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), toDateTime(max(unix_milli) / 1000) FROM %s", metricsTable)).Scan(&metrics, &metricsLastSeenAt); err == nil {
metricsLastSeenExpr := "toDateTime(max(unix_milli) / 1000)"
if q.hasColumn(ctx, metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4TableName, "inserted_at_unix_milli") {
metricsLastSeenExpr = "fromUnixTimestamp64Milli(max(inserted_at_unix_milli))"
}
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), %s FROM %s", metricsLastSeenExpr, metricsTable)).Scan(&metrics, &metricsLastSeenAt); err == nil {
stats["telemetry.metrics.count"] = metrics
if metricsLastSeenAt.Unix() != 0 {
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
@@ -63,3 +75,12 @@ func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, e
return stats, nil
}
func (q *querier) hasColumn(ctx context.Context, database, table, column string) bool {
var exists bool
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, "SELECT hasColumnInTable(?, ?, ?)", database, table, column).Scan(&exists); err != nil {
q.logger.DebugContext(ctx, "failed to check column existence", errors.Attr(err))
return false
}
return exists
}

View File

@@ -12,8 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/global/signozglobal"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/aiobservability/implaiobservability"
"github.com/SigNoz/signoz/pkg/modules/apdex"
"github.com/SigNoz/signoz/pkg/modules/apdex/implapdex"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
@@ -74,7 +72,6 @@ type Handlers struct {
FlaggerHandler flagger.Handler
GatewayHandler gateway.Handler
Fields fields.Handler
AIObservability aiobservability.Handler
AuthzHandler authz.Handler
ZeusHandler zeus.Handler
QuerierHandler querier.Handler
@@ -123,7 +120,6 @@ func NewHandlers(
FlaggerHandler: flagger.NewHandler(flaggerService),
GatewayHandler: gateway.NewHandler(gatewayService),
Fields: implfields.NewHandler(providerSettings, telemetryMetadataStore),
AIObservability: implaiobservability.NewHandler(telemetryMetadataStore),
AuthzHandler: signozauthzapi.NewHandler(authz),
ZeusHandler: zeus.NewHandler(zeusService, licensing),
QuerierHandler: querierHandler,

View File

@@ -17,7 +17,6 @@ import (
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/aiobservability"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -75,7 +74,6 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ inframonitoring.Handler }{},
struct{ gateway.Handler }{},
struct{ fields.Handler }{},
struct{ aiobservability.Handler }{},
struct{ authz.Handler }{},
struct{ rawdataexport.Handler }{},
struct{ zeus.Handler }{},

View File

@@ -240,7 +240,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
sqlmigration.NewRestructureAuthDomainConfigFactory(sqlstore),
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
)
}
@@ -326,7 +325,6 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.InfraMonitoring,
handlers.GatewayHandler,
handlers.Fields,
handlers.AIObservability,
handlers.AuthzHandler,
handlers.RawDataExport,
handlers.ZeusHandler,

View File

@@ -1,224 +0,0 @@
package sqlmigration
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
// storableSavedViewSelectFieldsRow is the shape of the `saved_view` table this migration repairs.
type storableSavedViewSelectFieldsRow struct {
bun.BaseModel `bun:"table:saved_view"`
ID string `bun:"id,pk,type:text"`
Data string `bun:"data,type:text"`
}
// selectedField is a superset of the current shape (name/signal/fieldContext/
// fieldDataType) and the legacy v1 shape (key/dataType/type) it replaced.
type selectedField struct {
Name string `json:"name"`
Signal string `json:"signal"`
FieldContext string `json:"fieldContext"`
FieldDataType string `json:"fieldDataType"`
Key string `json:"key"`
DataType string `json:"dataType"`
Type string `json:"type"`
}
// telemetryFieldKeyOutput is the current shape only.
type telemetryFieldKeyOutput struct {
Name string `json:"name"`
Signal string `json:"signal"`
FieldContext string `json:"fieldContext"`
FieldDataType string `json:"fieldDataType"`
}
// legacyTypeToFieldContext holds the legacy AttributeKeyType values whose current
// spelling differs. "tag" resolves to attribute through a telemetrytypes alias kept
// only for old DB entries, so store the current spelling rather than rely on it.
var legacyTypeToFieldContext = map[string]string{
"tag": "attribute",
"spanSearchScope": "span",
}
// legacyDataTypeToFieldDataType holds the legacy AttributeKeyDataType values with no
// matching telemetrytypes.FieldDataType alias.
var legacyDataTypeToFieldDataType = map[string]string{
"array(string)": "[]string",
"array(int64)": "[]int64",
"array(float64)": "[]float64",
"array(bool)": "[]bool",
}
type fixSavedViewSelectFields struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewFixSavedViewSelectFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("fix_saved_view_select_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &fixSavedViewSelectFields{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *fixSavedViewSelectFields) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *fixSavedViewSelectFields) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableSavedViewSelectFieldsRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var fixed, skipped int
for _, row := range rows {
fixedData, changed, ok := fixSelectFields(row.Data)
if !ok {
migration.settings.Logger.WarnContext(ctx, "saved view data could not be parsed, leaving it untouched", slog.String("saved_view_id", row.ID), slog.String("raw_data", row.Data))
skipped++
continue
}
if !changed {
continue
}
fixed++
if _, err := tx.NewUpdate().Model((*storableSavedViewSelectFieldsRow)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "fixed invalid saved view selectedFields entries", slog.Int("total", len(rows)), slog.Int("fixed", fixed), slog.Int("skipped", skipped))
return tx.Commit()
}
func (migration *fixSavedViewSelectFields) Down(context.Context, *bun.DB) error {
return nil
}
// marshalUnescaped encodes without json.Marshal's HTML escaping, which would
// otherwise rewrite <, > and & as \u003c, \u003e and \u0026 throughout the row --
// json.RawMessage included, so it reaches query expressions this migration only
// carries through.
func marshalUnescaped(v any) ([]byte, error) {
var buf bytes.Buffer
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(v); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}
func fieldContextFromLegacyType(legacyType string) string {
if mapped, ok := legacyTypeToFieldContext[legacyType]; ok {
return mapped
}
return legacyType
}
func fieldDataTypeFromLegacyDataType(legacyDataType string) string {
if mapped, ok := legacyDataTypeToFieldDataType[legacyDataType]; ok {
return mapped
}
return legacyDataType
}
// fixSelectFields recovers or drops entries in spec.selectedFields that never got
// mapped from the legacy key/dataType/type shape to the current
// name/fieldContext/fieldDataType shape. Entries that still carry a legacy key are
// recovered by renaming the fields; entries with neither a name nor a key are dropped
// as unrecoverable. Returns ok=false if data can't be parsed at all, and changed=false
// if there was nothing to fix.
func fixSelectFields(data string) (fixed string, changed bool, ok bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &raw); err != nil {
return "", false, false
}
var spec map[string]json.RawMessage
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
return "", false, false
}
selectedFieldsRaw, ok := spec["selectedFields"]
if !ok {
return "", false, true
}
var fieldsRaw []json.RawMessage
if err := json.Unmarshal(selectedFieldsRaw, &fieldsRaw); err != nil {
return "", false, false
}
fixedFields := make([]json.RawMessage, 0, len(fieldsRaw))
for _, rawField := range fieldsRaw {
var field selectedField
if err := json.Unmarshal(rawField, &field); err != nil {
return "", false, false
}
switch {
case field.Name != "":
// already valid -- keep the original bytes untouched, e.g. to preserve
// description/unit rather than dropping them by re-deriving the entry.
fixedFields = append(fixedFields, rawField)
case field.Key != "":
// legacy shape -- recover by renaming the fields.
recoveredJSON, err := marshalUnescaped(telemetryFieldKeyOutput{
Name: field.Key,
FieldContext: fieldContextFromLegacyType(field.Type),
FieldDataType: fieldDataTypeFromLegacyDataType(field.DataType),
})
if err != nil {
return "", false, false
}
fixedFields = append(fixedFields, recoveredJSON)
changed = true
default:
// neither name nor key -- unrecoverable, drop it.
changed = true
}
}
if !changed {
return "", false, true
}
fixedFieldsJSON, err := marshalUnescaped(fixedFields)
if err != nil {
return "", false, false
}
spec["selectedFields"] = fixedFieldsJSON
fixedSpec, err := marshalUnescaped(spec)
if err != nil {
return "", false, false
}
raw["spec"] = fixedSpec
fixedData, err := marshalUnescaped(raw)
if err != nil {
return "", false, false
}
return string(fixedData), true, true
}

View File

@@ -7,9 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/statementbuilder"
scopedtraces "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/aitelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -27,7 +25,7 @@ func NewFactory(
// Scope describes gen_ai for the scoped trace builder: an AI trace has >=1 gen_ai
// LLM, tool, or agent span, and its list adds AI/LLM per-trace metrics.
func Scope() scopedtraces.TraceScope {
gateKeyNames := []string{aiobservabilitytypes.GenAIRequestModel, aiobservabilitytypes.GenAIToolName, aiobservabilitytypes.GenAIAgentName}
gateKeyNames := []string{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName}
gateExprs := make([]string, 0, len(gateKeyNames))
gateKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(gateKeyNames))
for _, name := range gateKeyNames {
@@ -39,14 +37,14 @@ func Scope() scopedtraces.TraceScope {
})
}
defs := aitelemetryschema.GenAIFields
reqModel := defs[aiobservabilitytypes.GenAIRequestModel]
toolName := defs[aiobservabilitytypes.GenAIToolName]
inTok := defs[aiobservabilitytypes.GenAIUsageInputTokens]
outTok := defs[aiobservabilitytypes.GenAIUsageOutputTokens]
cost := defs[aiobservabilitytypes.SignozGenAITotalCost]
inMsg := defs[aiobservabilitytypes.GenAIInputMessages]
outMsg := defs[aiobservabilitytypes.GenAIOutputMessages]
defs := telemetrytypes.GenAIFieldDefinitions
reqModel := defs[telemetrytypes.GenAIRequestModel]
toolName := defs[telemetrytypes.GenAIToolName]
inTok := defs[telemetrytypes.GenAIUsageInputTokens]
outTok := defs[telemetrytypes.GenAIUsageOutputTokens]
cost := defs[telemetrytypes.SignozGenAITotalCost]
inMsg := defs[telemetrytypes.GenAIInputMessages]
outMsg := defs[telemetrytypes.GenAIOutputMessages]
str := telemetrytypes.FieldDataTypeString
columns := append(scopedtraces.CommonTraceColumns(),
@@ -64,20 +62,13 @@ func Scope() scopedtraces.TraceScope {
scopedtraces.TraceColumn{Alias: "max_llm_duration_nano", Orderable: true, Expr: scopedtraces.ScopedToKeyColumn(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("duration_nano"), &reqModel)},
// errors across the whole trace (any span), so display-only.
scopedtraces.TraceColumn{Alias: "error_count", Expr: scopedtraces.CondCount(scopedtraces.IntrinsicSpanKey("has_error"), qbtypes.FilterOperatorEqual, true)},
// timestamp of the last gen_ai span (LLM/tool/agent), hence gate-scoped;
// order-only: a raw-nanos threshold makes no sense in the filter bar.
// timestamp of the last gen_ai span (LLM/tool/agent), hence gate-scoped.
scopedtraces.TraceColumn{Alias: "last_activity_time", Orderable: true, Expr: scopedtraces.ScopedReduce(scopedtraces.AggMax, scopedtraces.IntrinsicSpanKey("timestamp"))},
// previews: first call's input (the prompt), last call's output (the answer).
scopedtraces.TraceColumn{Alias: "input", SpanLevel: true, Expr: scopedtraces.PickBy(&inMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickEarliest)},
scopedtraces.TraceColumn{Alias: "output", SpanLevel: true, Expr: scopedtraces.PickBy(&outMsg, str, scopedtraces.IntrinsicSpanKey("timestamp"), scopedtraces.PickLatest)},
)
for i, c := range columns {
if _, ok := aitelemetryschema.TraceAggregateFields[c.Alias]; ok {
columns[i].Filterable = true
}
}
return scopedtraces.TraceScope{
FilterExpression: strings.Join(gateExprs, " OR "),
FieldKeys: gateKeys,

View File

@@ -11,8 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/statementbuilder"
scopedtraces "github.com/SigNoz/signoz/pkg/statementbuilder/scopedtracesstatementbuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/aitelemetryschema"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
@@ -42,7 +40,8 @@ func otelKeysMap() map[string][]*telemetrytypes.TelemetryFieldKey {
m := make(map[string][]*telemetrytypes.TelemetryFieldKey)
for name, def := range aitelemetryschema.GenAIFields {
// mirrors what enrichWithGenAIKeys surfaces in production
for name, def := range telemetrytypes.GenAIFieldDefinitions {
keyCopy := def
m[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
}
@@ -977,8 +976,8 @@ func TestBuild_UnsupportedRequestType(t *testing.T) {
// mask, OR-combined.
func TestBuild_TraceList_MultiVariantGateKey(t *testing.T) {
keys := otelKeysMap()
keys[aiobservabilitytypes.GenAIToolName] = append(keys[aiobservabilitytypes.GenAIToolName], &telemetrytypes.TelemetryFieldKey{
Name: aiobservabilitytypes.GenAIToolName,
keys[telemetrytypes.GenAIToolName] = append(keys[telemetrytypes.GenAIToolName], &telemetrytypes.TelemetryFieldKey{
Name: telemetrytypes.GenAIToolName,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,

View File

@@ -24,10 +24,9 @@ type TraceColumn struct {
// ClickHouse resolves bare identifiers to same-SELECT aliases first, so any
// expression referencing that column would silently bind to the alias.
Alias string
// Orderable columns can be used in ORDER BY, Filterable ones in the aggregate
// filter; all-span aggregates are display-only and set neither.
Orderable bool
Filterable bool
// Orderable columns can be used in ORDER BY and the aggregate filter; all-span
// aggregates are display-only and set false.
Orderable bool
// SpanLevel columns surface a real span/resource attribute; a filter on them is
// applied span-level, so they are excluded from the trace-level aliases.
SpanLevel bool

View File

@@ -185,19 +185,18 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
return nil, err
}
orderableSet := orderableAliasSet(resolved)
filterableSet := filterableAliasSet(resolved)
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
if err != nil {
return nil, err
}
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), filterableSet, start, end, variables, matchedSB)
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), orderableSet, start, end, variables, matchedSB)
if err != nil {
return nil, err
}
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, filterableSet, maskExpr, fp, resourcePred, limit, query.Offset)
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, fp, resourcePred, limit, query.Offset)
if err != nil {
return nil, err
}
@@ -327,10 +326,9 @@ func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID val
}
type resolvedColumn struct {
alias string
expr string
orderable bool
filterable bool
alias string
expr string
orderable bool
}
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, cols *columnResolver, preds *predicateResolver) ([]resolvedColumn, error) {
@@ -340,7 +338,7 @@ func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID
if err != nil {
return nil, err
}
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable, filterable: c.Filterable})
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable})
}
return out, nil
}
@@ -395,7 +393,7 @@ type filterParts struct {
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
// and a trace-level HAVING (explicit query.Having ANDed on), then validates the
// trace-level part against the matched-pass aggregates.
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, filterableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, orderableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
var fp filterParts
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
@@ -431,7 +429,7 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID val
}
fp.havingExpr = replaced
}
if err := validateAggregateFilter(fp.havingExpr, filterableSet); err != nil {
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
return fp, err
}
return fp, nil
@@ -475,7 +473,7 @@ func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context,
// span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the aliases ORDER BY
// / HAVING reference. Expressions carry $n markers bound to sb, so each can appear
// several times and every occurrence resolves to the same arg.
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet, filterableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
selects := []string{"trace_id"}
for _, rc := range resolved {
@@ -515,8 +513,8 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
}
if strings.TrimSpace(fp.havingExpr) != "" {
// the rewriter matches raw key text, so map the trace. form alongside the bare name
columnMap := make(map[string]string, len(filterableSet)*2)
for a := range filterableSet {
columnMap := make(map[string]string, len(orderableSet)*2)
for a := range orderableSet {
columnMap[a] = quoteAlias(a)
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
}
@@ -605,17 +603,6 @@ func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
return set
}
// filterableAliasSet is the subset of aliases usable in the trace-level filter.
func filterableAliasSet(resolved []resolvedColumn) map[string]struct{} {
set := make(map[string]struct{})
for _, rc := range resolved {
if rc.filterable {
set[rc.alias] = struct{}{}
}
}
return set
}
// neededMatchedAliases is the minimal alias set the matched pass must select: those
// in ORDER BY plus those in the aggregate HAVING.
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
@@ -643,19 +630,19 @@ func traceAggregateNames(havingExpr string) []string {
return names
}
// validateAggregateFilter rejects a trace-level filter referencing an aggregate that
// is not filterable.
func validateAggregateFilter(havingExpr string, filterableSet map[string]struct{}) error {
// validateAggregateFilter rejects a trace-level filter referencing an aggregate not
// computable in the matched pass.
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
if strings.TrimSpace(havingExpr) == "" {
return nil
}
allowed := make([]string, 0, len(filterableSet))
for a := range filterableSet {
allowed := make([]string, 0, len(orderableSet))
for a := range orderableSet {
allowed = append(allowed, a)
}
sort.Strings(allowed)
for _, name := range traceAggregateNames(havingExpr) {
if _, ok := filterableSet[name]; !ok {
if _, ok := orderableSet[name]; !ok {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
}

View File

@@ -1168,6 +1168,27 @@ func enrichWithIntrinsicMetricKeys(keys map[string][]*telemetrytypes.TelemetryFi
return keys
}
// enrichWithGenAIKeys adds keys that can be queried for GenAI signals, even though they have not been ingested yet.
func enrichWithGenAIKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selectors []*telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
for _, selector := range selectors {
if selector.Signal != telemetrytypes.SignalTraces && selector.Signal != telemetrytypes.SignalUnspecified {
continue
}
for name, def := range telemetrytypes.GenAIFieldDefinitions {
if len(keys[name]) > 0 {
continue // already resolved from ingested data
}
if !selectorMatchesIntrinsicField(selector, def) {
continue
}
keyCopy := def
keys[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
}
}
return keys
}
func selectorMatchesIntrinsicField(selector *telemetrytypes.FieldKeySelector, definition telemetrytypes.TelemetryFieldKey) bool {
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && selector.FieldContext != definition.FieldContext {
return false
@@ -1253,6 +1274,9 @@ func (t *telemetryMetaStore) GetKeys(ctx context.Context, orgID valuer.UUID, fie
applyBackwardCompatibleKeys(mapOfKeys)
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, selectors)
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, selectors)
}
return mapOfKeys, complete, nil
}
@@ -1331,6 +1355,9 @@ func (t *telemetryMetaStore) GetKeysMulti(ctx context.Context, orgID valuer.UUID
applyBackwardCompatibleKeys(mapOfKeys)
mapOfKeys = enrichWithIntrinsicMetricKeys(mapOfKeys, fieldKeySelectors)
if t.fl.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, featuretypes.NewFlaggerEvaluationContext(orgID)) {
mapOfKeys = enrichWithGenAIKeys(mapOfKeys, fieldKeySelectors)
}
return mapOfKeys, complete, nil
}

View File

@@ -1,58 +0,0 @@
package aitelemetryschema
import (
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
var (
// GenAIFields are the gen_ai span attributes the AI query builder relies on,
// suggested before ingestion so the filter bar works on a fresh install.
GenAIFields = map[string]telemetrytypes.TelemetryFieldKey{
aiobservabilitytypes.GenAIRequestModel: genAIAttribute(aiobservabilitytypes.GenAIRequestModel, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIOperationName: genAIAttribute(aiobservabilitytypes.GenAIOperationName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIToolName: genAIAttribute(aiobservabilitytypes.GenAIToolName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIAgentName: genAIAttribute(aiobservabilitytypes.GenAIAgentName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIProviderName: genAIAttribute(aiobservabilitytypes.GenAIProviderName, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeFloat64),
aiobservabilitytypes.GenAIInputMessages: genAIAttribute(aiobservabilitytypes.GenAIInputMessages, telemetrytypes.FieldDataTypeString),
aiobservabilitytypes.GenAIOutputMessages: genAIAttribute(aiobservabilitytypes.GenAIOutputMessages, telemetrytypes.FieldDataTypeString),
}
// TraceAggregateFields are the per-trace aggregates the AI trace list computes;
// they are never ingested, so only this definition can surface them.
TraceAggregateFields = map[string]telemetrytypes.TelemetryFieldKey{
"llm_call_count": traceAggregate("llm_call_count"),
"tool_call_count": traceAggregate("tool_call_count"),
"distinct_tool_count": traceAggregate("distinct_tool_count"),
"input_tokens": traceAggregate("input_tokens"),
"output_tokens": traceAggregate("output_tokens"),
"total_tokens": traceAggregate("total_tokens"),
"estimated_total_cost": traceAggregate("estimated_total_cost"),
"max_llm_duration_nano": traceAggregate("max_llm_duration_nano"),
}
)
func genAIAttribute(name string, dataType telemetrytypes.FieldDataType) telemetrytypes.TelemetryFieldKey {
return telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: dataType,
}
}
func traceAggregate(name string) telemetrytypes.TelemetryFieldKey {
return telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextTrace,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
}
}

View File

@@ -1,26 +0,0 @@
package aitelemetryschema
import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// FieldKeys merges in the keys the metadata store cannot serve: the computed
// aggregates always, the gen_ai attributes only where ingestion has not.
func FieldKeys(keys map[string][]*telemetrytypes.TelemetryFieldKey, selector *telemetrytypes.FieldKeySelector) map[string][]*telemetrytypes.TelemetryFieldKey {
for name, def := range TraceAggregateFields {
if selector.MatchesKey(&def) {
keys[name] = append(keys[name], &def)
}
}
for name, def := range GenAIFields {
if len(keys[name]) > 0 {
continue
}
if selector.MatchesKey(&def) {
keys[name] = []*telemetrytypes.TelemetryFieldKey{&def}
}
}
return keys
}

View File

@@ -1,46 +0,0 @@
package aiobservabilitytypes
import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// the explorer lists AI traces, so the signal is always traces and the metric
// selectors do not apply.
type PostableFieldKeysParams struct {
SearchText string `query:"searchText"`
FieldContext telemetrytypes.FieldContext `query:"fieldContext"`
FieldDataType telemetrytypes.FieldDataType `query:"fieldDataType"`
StartUnixMilli int64 `query:"startUnixMilli"`
EndUnixMilli int64 `query:"endUnixMilli"`
Limit int `query:"limit"`
}
// existingQuery is unsupported until the computed per-trace aggregates it may
// reference can be narrowed on.
type PostableFieldValueParams struct {
PostableFieldKeysParams
Name string `query:"name"`
}
func NewFieldKeySelectorFromPostableFieldKeysParams(params PostableFieldKeysParams) *telemetrytypes.FieldKeySelector {
return telemetrytypes.NewFieldKeySelectorFromPostableFieldKeysParams(params.telemetryParams())
}
func NewFieldValueSelectorFromPostableFieldValueParams(params PostableFieldValueParams) *telemetrytypes.FieldValueSelector {
return telemetrytypes.NewFieldValueSelectorFromPostableFieldValueParams(telemetrytypes.PostableFieldValueParams{
PostableFieldKeysParams: params.telemetryParams(),
Name: params.Name,
})
}
func (params PostableFieldKeysParams) telemetryParams() telemetrytypes.PostableFieldKeysParams {
return telemetrytypes.PostableFieldKeysParams{
Signal: telemetrytypes.SignalTraces,
SearchText: params.SearchText,
FieldContext: params.FieldContext,
FieldDataType: params.FieldDataType,
StartUnixMilli: params.StartUnixMilli,
EndUnixMilli: params.EndUnixMilli,
Limit: params.Limit,
}
}

View File

@@ -1,28 +0,0 @@
package aiobservabilitytypes
// OpenTelemetry gen_ai semantic-convention attribute keys. Single source of truth
// shared by the AI query builder and the LLM pricing pipeline.
const (
GenAIRequestModel = "gen_ai.request.model"
GenAIOperationName = "gen_ai.operation.name"
GenAIToolName = "gen_ai.tool.name"
GenAIAgentName = "gen_ai.agent.name"
GenAIProviderName = "gen_ai.provider.name"
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
)
// Per-span costs the SigNoz LLM pricing processor attaches; not OTel semconv.
const (
SignozGenAICostInput = "_signoz.gen_ai.cost_input"
SignozGenAICostOutput = "_signoz.gen_ai.cost_output"
SignozGenAICostCacheRead = "_signoz.gen_ai.cost_cache_read"
SignozGenAICostCacheWrite = "_signoz.gen_ai.cost_cache_write"
SignozGenAITotalCost = "_signoz.gen_ai.total_cost"
)

View File

@@ -15,6 +15,11 @@ import (
const (
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
SignozGenAICostInput = "_signoz.gen_ai.cost_input"
SignozGenAICostOutput = "_signoz.gen_ai.cost_output"
SignozGenAICostCacheRead = "_signoz.gen_ai.cost_cache_read"
SignozGenAICostCacheWrite = "_signoz.gen_ai.cost_cache_write"
)
var (

View File

@@ -4,7 +4,7 @@ import (
"bytes"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"gopkg.in/yaml.v3"
)
@@ -84,21 +84,21 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
return &LLMPricingRuleProcessorConfig{
Attrs: LLMPricingRuleProcessorAttrs{
Model: aiobservabilitytypes.GenAIRequestModel,
In: aiobservabilitytypes.GenAIUsageInputTokens,
Out: aiobservabilitytypes.GenAIUsageOutputTokens,
CacheRead: aiobservabilitytypes.GenAIUsageCacheReadInputTokens,
CacheWrite: aiobservabilitytypes.GenAIUsageCacheCreationInputTokens,
Model: telemetrytypes.GenAIRequestModel,
In: telemetrytypes.GenAIUsageInputTokens,
Out: telemetrytypes.GenAIUsageOutputTokens,
CacheRead: telemetrytypes.GenAIUsageCacheReadInputTokens,
CacheWrite: telemetrytypes.GenAIUsageCacheCreationInputTokens,
},
DefaultPricing: LLMPricingRuleProcessorDefaultPricing{
Rules: pricingRules,
},
OutputAttrs: LLMPricingRuleProcessorOutputAttrs{
In: aiobservabilitytypes.SignozGenAICostInput,
Out: aiobservabilitytypes.SignozGenAICostOutput,
CacheRead: aiobservabilitytypes.SignozGenAICostCacheRead,
CacheWrite: aiobservabilitytypes.SignozGenAICostCacheWrite,
Total: aiobservabilitytypes.SignozGenAITotalCost,
In: SignozGenAICostInput,
Out: SignozGenAICostOutput,
CacheRead: SignozGenAICostCacheRead,
CacheWrite: SignozGenAICostCacheWrite,
Total: telemetrytypes.SignozGenAITotalCost,
},
}
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -195,12 +195,12 @@ func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
// usage: env scoping, the LLM identity keys, then service and the rest.
aiObservabilityFilters := []map[string]interface{}{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": aiobservabilitytypes.GenAIOperationName, "dataType": "string", "type": "tag"},
{"key": aiobservabilitytypes.GenAIProviderName, "dataType": "string", "type": "tag"},
{"key": aiobservabilitytypes.GenAIRequestModel, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIOperationName, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIProviderName, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIRequestModel, "dataType": "string", "type": "tag"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": aiobservabilitytypes.GenAIToolName, "dataType": "string", "type": "tag"},
{"key": aiobservabilitytypes.GenAIAgentName, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIToolName, "dataType": "string", "type": "tag"},
{"key": telemetrytypes.GenAIAgentName, "dataType": "string", "type": "tag"},
}
tracesJSON, err := json.Marshal(tracesFilters)

View File

@@ -92,11 +92,6 @@ func (s *SavedViewSpec) Validate() error {
if s.RequestType.IsZero() {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "requestType is required")
}
for i, field := range s.SelectedFields {
if field.Name == "" {
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "selectedFields[%d].name is required", i)
}
}
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate(qbtypes.GetValidationOptions(s.RequestType)...)
}

View File

@@ -99,17 +99,6 @@ func TestSavedViewSpecValidate(t *testing.T) {
},
expectError: false,
},
{
name: "selectedFields entry with no name is rejected",
spec: SavedViewSpec{
DisplayName: "My View",
PanelType: PanelTypeTable,
RequestType: qbtypes.RequestTypeScalar,
Queries: validQueries(),
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}, {}},
},
expectError: true,
},
{
name: "nil selectedFields is valid -- selectedFields itself is not required",
spec: SavedViewSpec{

View File

@@ -246,27 +246,6 @@ type FieldKeySelector struct {
MetricContext *MetricContext `json:"metricContext,omitempty"`
}
// MatchesKey reports whether a statically defined key satisfies the selector, so
// callers can suggest keys that were never ingested.
func (s *FieldKeySelector) MatchesKey(key *TelemetryFieldKey) bool {
if s.FieldContext != FieldContextUnspecified && s.FieldContext != key.FieldContext {
return false
}
if s.FieldDataType != FieldDataTypeUnspecified && s.FieldDataType != key.FieldDataType {
return false
}
if s.Name == "" {
return true
}
if s.SelectorMatchType == FieldSelectorMatchTypeExact {
return strings.EqualFold(s.Name, key.Name)
}
return strings.Contains(strings.ToLower(key.Name), strings.ToLower(s.Name))
}
type FieldValueSelector struct {
*FieldKeySelector
ExistingQuery string `json:"existingQuery"`

View File

@@ -188,7 +188,7 @@ func (FieldContext) Enum() []any {
FieldContextMetric,
FieldContextLog,
FieldContextSpan,
FieldContextTrace,
// FieldContextTrace,
FieldContextResource,
// FieldContextScope,
FieldContextAttribute,

View File

@@ -0,0 +1,43 @@
package telemetrytypes
// OpenTelemetry gen_ai semantic-convention attribute keys. Single source of truth
// shared by the AI query builder and the LLM pricing pipeline.
const (
GenAIRequestModel = "gen_ai.request.model"
GenAIOperationName = "gen_ai.operation.name"
GenAIToolName = "gen_ai.tool.name"
GenAIAgentName = "gen_ai.agent.name"
GenAIProviderName = "gen_ai.provider.name"
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
GenAIInputMessages = "gen_ai.input.messages"
GenAIOutputMessages = "gen_ai.output.messages"
// SignozGenAITotalCost is not OTel semconv: it is the per-span cost the SigNoz
// LLM pricing processor attaches.
SignozGenAITotalCost = "_signoz.gen_ai.total_cost"
)
// GenAIFieldDefinitions are the gen_ai span attributes the AI query builder relies
// on, surfaced by the metadata store even before ingestion so the AI gate/columns
// resolve on a fresh install.
var GenAIFieldDefinitions = map[string]TelemetryFieldKey{
GenAIRequestModel: {Name: GenAIRequestModel, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIOperationName: {Name: GenAIOperationName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIToolName: {Name: GenAIToolName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIAgentName: {Name: GenAIAgentName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIProviderName: {Name: GenAIProviderName, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIUsageInputTokens: {Name: GenAIUsageInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIUsageOutputTokens: {Name: GenAIUsageOutputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIUsageCacheReadInputTokens: {Name: GenAIUsageCacheReadInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIUsageCacheCreationInputTokens: {Name: GenAIUsageCacheCreationInputTokens, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
SignozGenAITotalCost: {Name: SignozGenAITotalCost, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeFloat64},
GenAIInputMessages: {Name: GenAIInputMessages, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
GenAIOutputMessages: {Name: GenAIOutputMessages, Signal: SignalTraces, FieldContext: FieldContextAttribute, FieldDataType: FieldDataTypeString},
}

View File

@@ -6,7 +6,6 @@ from typing import Any
import numpy as np
import pytest
import requests
from fixtures import types
from fixtures.fingerprint import LogsOrTracesFingerprint
@@ -107,24 +106,6 @@ def truncate_attributes_metadata_table(conn, cluster: str) -> None:
conn.query(f"TRUNCATE TABLE signoz_metadata.attributes_metadata ON CLUSTER '{cluster}' SYNC")
def get_field_keys(signoz: types.SigNoz, token: str, params: dict, path: str = "/api/v1/fields/keys") -> requests.Response:
return requests.get(
signoz.self.host_configs["8080"].get(path),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params=params,
)
def get_field_values(signoz: types.SigNoz, token: str, params: dict, path: str = "/api/v1/fields/values") -> requests.Response:
return requests.get(
signoz.self.host_configs["8080"].get(path),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params=params,
)
@pytest.fixture(name="insert_attributes_metadata", scope="function")
def insert_attributes_metadata(
clickhouse: types.TestContainerClickhouse,

View File

@@ -1,137 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metadata import get_field_keys, get_field_values
from fixtures.querierai import ai_trace
from fixtures.traces import Traces
AI_KEYS_PATH = "/api/v1/ai_observability/fields/keys"
AI_VALUES_PATH = "/api/v1/ai_observability/fields/values"
# The filterable per-trace aggregates; the display-only columns (error_count,
# last_activity_time, span_count, input, output) must not be suggested.
AI_TRACE_AGGREGATES = {
"llm_call_count",
"tool_call_count",
"distinct_tool_count",
"input_tokens",
"output_tokens",
"total_tokens",
"estimated_total_cost",
"max_llm_duration_nano",
}
def test_ai_fields_lists_filterable_aggregates(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_keys(signoz, token, {}, AI_KEYS_PATH)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
trace_context_names = {name for name, variants in keys.items() if any(key["fieldContext"] == "trace" for key in variants)}
assert trace_context_names == AI_TRACE_AGGREGATES, keys
def test_ai_fields_trace_prefix_search(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# `trace.output` in the filter bar parses into the trace context
response = get_field_keys(signoz, token, {"searchText": "trace.output"}, AI_KEYS_PATH)
assert response.status_code == HTTPStatus.OK, response.text
keys = response.json()["data"]["keys"]
trace_context_names = {name for name, variants in keys.items() if any(key["fieldContext"] == "trace" for key in variants)}
assert trace_context_names == {"output_tokens"}, keys
def test_ai_fields_bare_prefix_suggests_aggregate_and_attribute(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_keys(signoz, token, {"searchText": "output_tok"}, AI_KEYS_PATH)
assert response.status_code == HTTPStatus.OK, response.text
keys = response.json()["data"]["keys"]
assert any(key["fieldContext"] == "trace" for key in keys["output_tokens"]), keys
assert any(key["fieldContext"] == "attribute" for key in keys["gen_ai.usage.output_tokens"]), keys
def test_ai_fields_are_not_served_by_the_generic_endpoint(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_keys(signoz, token, {"signal": "traces", "searchText": "output_tok"})
assert response.status_code == HTTPStatus.OK, response.text
keys = response.json()["data"]["keys"]
assert "output_tokens" not in keys, keys
assert "gen_ai.usage.output_tokens" not in keys, keys
def test_ai_field_values_suggests_ingested_attribute_values(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(ai_trace(now=now, service="ai-it-values", user="alice", in_tokens=100, out_tokens=20, cost=0.5, model="gpt-it-values"))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(signoz, token, {"name": "gen_ai.request.model", "searchText": "gpt-it-values"}, AI_VALUES_PATH)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]
assert values["stringValues"] == ["gpt-it-values"], values
def test_ai_field_values_reject_existing_query(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(
signoz,
token,
{"name": "gen_ai.request.model", "existingQuery": "service.name = 'ai-it-values'"},
AI_VALUES_PATH,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
def test_ai_field_values_of_computed_aggregate_are_empty(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = get_field_values(signoz, token, {"name": "llm_call_count", "fieldContext": "trace"}, AI_VALUES_PATH)
assert response.status_code == HTTPStatus.OK, response.text
values = response.json()["data"]["values"]
assert values.get("stringValues", []) == [], values
assert values.get("numberValues", []) == [], values