mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-17 18:30:31 +01:00
Compare commits
6 Commits
feat/initi
...
fix/apm-dr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e1b611a4c | ||
|
|
edb63ae7be | ||
|
|
e7dc01de45 | ||
|
|
c40ebb027b | ||
|
|
789a4626fc | ||
|
|
2dcd4d9a66 |
@@ -8805,6 +8805,7 @@ components:
|
||||
- metric
|
||||
- log
|
||||
- span
|
||||
- trace
|
||||
- resource
|
||||
- attribute
|
||||
- body
|
||||
@@ -9115,6 +9116,158 @@ 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
|
||||
|
||||
@@ -64,6 +64,5 @@
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping",
|
||||
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer"
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
}
|
||||
@@ -89,6 +89,5 @@
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"AI_OBSERVABILITY_OVERVIEW": "SigNoz | AI Observability Overview",
|
||||
"AI_OBSERVABILITY_CONFIGURATION": "SigNoz | AI Observability Configuration",
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping",
|
||||
"AI_OBSERVABILITY_EXPLORER": "SigNoz | AI Observability Explorer"
|
||||
"AI_OBSERVABILITY_ATTRIBUTE_MAPPING": "SigNoz | AI Observability Attribute Mapping"
|
||||
}
|
||||
236
frontend/src/api/generated/services/ai-observability/index.ts
Normal file
236
frontend/src/api/generated/services/ai-observability/index.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* ! 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;
|
||||
};
|
||||
@@ -3490,6 +3490,7 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
metric = 'metric',
|
||||
log = 'log',
|
||||
span = 'span',
|
||||
trace = 'trace',
|
||||
resource = 'resource',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
@@ -10175,6 +10176,93 @@ 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
|
||||
|
||||
@@ -11,8 +11,6 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
AI_OBSERVABILITY_LIST_COLUMNS = 'AI_OBSERVABILITY_LIST_COLUMNS',
|
||||
AI_OBSERVABILITY_TRACE_VIEW_COLUMNS = 'AI_OBSERVABILITY_TRACE_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import useUpdatedQuery from '../useResolveQuery';
|
||||
|
||||
const mockGetSubstituteVars = jest.fn();
|
||||
const mockDynamicVariables: unknown[] = [];
|
||||
|
||||
jest.mock('api/dashboard/substitute_vars', () => ({
|
||||
getSubstituteVars: (...args: unknown[]): unknown =>
|
||||
mockGetSubstituteVars(...args),
|
||||
}));
|
||||
|
||||
jest.mock('api/v5/v5', () => ({
|
||||
prepareQueryRangePayloadV5: (): { queryPayload: unknown } => ({
|
||||
queryPayload: { start: 0, end: 1 },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
() => ({
|
||||
mapQueryDataFromApi: (): Query => ({ resolved: true }) as unknown as Query,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
|
||||
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: (): unknown => ({
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
}),
|
||||
}));
|
||||
|
||||
const QUERY = { builder: { queryData: [] } } as unknown as Query;
|
||||
|
||||
const WIDGET_CONFIG = {
|
||||
query: QUERY,
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
timePreferance: 'GLOBAL_TIME' as const,
|
||||
};
|
||||
|
||||
describe('useResolveQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDynamicVariables.length = 0;
|
||||
});
|
||||
|
||||
it('skips the substitute_vars round-trip when there are no variables', async () => {
|
||||
const { result } = renderHook(() => useUpdatedQuery(), {
|
||||
wrapper: MockQueryClientProvider,
|
||||
});
|
||||
|
||||
const resolved = await result.current.getUpdatedQuery({
|
||||
widgetConfig: WIDGET_CONFIG,
|
||||
});
|
||||
|
||||
expect(mockGetSubstituteVars).not.toHaveBeenCalled();
|
||||
expect(resolved).toBe(QUERY);
|
||||
});
|
||||
|
||||
it('resolves through substitute_vars when the dashboard has variables', async () => {
|
||||
mockGetSubstituteVars.mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { compositeQuery: {} },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdatedQuery(), {
|
||||
wrapper: MockQueryClientProvider,
|
||||
});
|
||||
|
||||
const resolved = await result.current.getUpdatedQuery({
|
||||
widgetConfig: WIDGET_CONFIG,
|
||||
dashboardData: {
|
||||
data: {
|
||||
variables: {
|
||||
env: { name: 'env', selectedValue: 'prod' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);
|
||||
expect(resolved).toStrictEqual({ resolved: true });
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
|
||||
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -46,13 +47,21 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
|
||||
widgetConfig,
|
||||
dashboardData,
|
||||
}: UseUpdatedQueryOptions): Promise<Query> => {
|
||||
const variables = getDashboardVariables(dashboardData?.data?.variables);
|
||||
|
||||
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
|
||||
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
|
||||
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
|
||||
return widgetConfig.query;
|
||||
}
|
||||
|
||||
// Prepare query payload with resolved variables
|
||||
const { queryPayload } = prepareQueryRangePayloadV5({
|
||||
query: widgetConfig.query,
|
||||
graphType: getGraphType(widgetConfig.panelTypes),
|
||||
selectedTime: widgetConfig.timePreferance,
|
||||
globalSelectedInterval,
|
||||
variables: getDashboardVariables(dashboardData?.data?.variables),
|
||||
variables,
|
||||
originalGraphType: widgetConfig.panelTypes,
|
||||
dynamicVariables: dashboardDynamicVariables,
|
||||
});
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.optionsTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: pointer;
|
||||
|
||||
// Resets button chrome: this was a bare div in the traces explorer.
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function ExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: ExplorerControlsProps): JSX.Element | null {
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
data-testid="explorer-controls-options"
|
||||
>
|
||||
Options
|
||||
<Settings size="md" />
|
||||
</button>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
ExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(ExplorerControls);
|
||||
@@ -1,42 +1,11 @@
|
||||
.explorerPage {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
border-right: 0px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> :global(.ant-card-body) {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
&.isFiltersExpanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-0);
|
||||
}
|
||||
|
||||
.views {
|
||||
padding: var(--spacing-4);
|
||||
// Room for the floating options bar this explorer doesn't render yet.
|
||||
padding-bottom: 60px;
|
||||
margin-bottom: var(--spacing-12);
|
||||
.placeholder {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
@@ -1,243 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { QueryKey, useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
|
||||
import { defaultSelectedColumns, TOOLBAR_VIEWS } from './constants';
|
||||
import styles from './Explorer.module.scss';
|
||||
import ListView from './ListView/ListView';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
import TracesView from './TracesView/TracesView';
|
||||
|
||||
// Forked from the Traces Explorer; diverges as the GenAI query surface lands.
|
||||
// Shell for the AI Observability Explorer tab. Owns the
|
||||
// /ai-observability/explorer route and is intentionally empty for now: the
|
||||
// query builder + results surface land in a follow-up.
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
handleSetConfig,
|
||||
} = useQueryBuilder();
|
||||
|
||||
// TODO(ai-explorer): destructure `{ options }` when save-view / add-to-dashboard
|
||||
// land (Traces Explorer passes it to getExportQueryData). Until then the call
|
||||
// only seeds `?options=` for views that do not mount ListView.
|
||||
// TODO: shares the Traces Explorer's saved columns; needs its own ai_o11y key.
|
||||
useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<QueryKey>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
void queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// The active view unmounts on cancel, so no child will reset this.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
);
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
void logEvent('AI Observability Explorer: Page visited', {});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
// if any of the non-disabled queries has filters applied, return true
|
||||
const result = stagedQuery?.builder?.queryData?.filter(
|
||||
(item) => !isEmpty(item.filters?.items) && !item.disabled,
|
||||
);
|
||||
return !!result?.length;
|
||||
}, [stagedQuery]);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className={styles.explorerPage}
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<Card className={styles.filter} hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx(styles.explorer, {
|
||||
[styles.isFiltersExpanded]: isOpen,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
leftActions={
|
||||
<LeftToolbarActions
|
||||
showFilter={isOpen}
|
||||
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
|
||||
items={TOOLBAR_VIEWS}
|
||||
selectedView={selectedView}
|
||||
onChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
}
|
||||
warningElement={
|
||||
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>
|
||||
<div className="query-section-container">
|
||||
<QuerySection />
|
||||
</div>
|
||||
</ExplorerCard>
|
||||
|
||||
<div className={styles.views}>
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
<div className={styles.explorer} data-testid="llm-observability-explorer">
|
||||
<div className={styles.placeholder}>Explorer coming soon.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--typography-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
// Offset clears the toolbar, query builder, and controls row above the table.
|
||||
.table {
|
||||
max-height: calc(100vh - 360px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.orderByContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.orderByLabel {
|
||||
color: var(--muted-foreground);
|
||||
// Between --periscope-font-size-small (11px) and -base (13px), so literal.
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from '../constants';
|
||||
import ExplorerControls from '../Controls/Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import styles from './ListView.module.scss';
|
||||
import { useListTableColumns } from './useListTableColumns';
|
||||
import { TraceListRow } from '../tableUtils';
|
||||
import { getTraceLink, getTraceRowKey, transformDataWithDate } from './utils';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
// TODO: column edits leak to Traces Explorer; needs its own ai_o11y key.
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Query-key slice for selectColumns: stable on reorder, changes on
|
||||
// add/remove/replace. Composite key so resource.foo ≠ attribute.foo.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => buildCompositeKey(c.name, c.fieldContext))
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const dataLength =
|
||||
data?.payload?.data?.newResult?.data?.result[0]?.list?.length;
|
||||
const totalCount = useMemo(() => dataLength || 0, [dataLength]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const columns = useListTableColumns(options?.selectColumns || []);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(queryTableData) || [],
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TraceListRow>[]): void => {
|
||||
// Column ids are composite (fieldContext.name) — disambiguates same-name fields.
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TraceListRow): void => {
|
||||
safeNavigate(getTraceLink(row));
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback((row: TraceListRow): void => {
|
||||
window.open(getAbsoluteUrl(getTraceLink(row)), '_blank');
|
||||
}, []);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length !== 0
|
||||
) {
|
||||
void logEvent('AI Observability Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.orderByContainer}>
|
||||
<div className={styles.orderByLabel}>
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<ExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={totalCount}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && transformedQueryTableData.length !== 0 && (
|
||||
<TanStackTable<TraceListRow>
|
||||
data={transformedQueryTableData}
|
||||
columns={columns}
|
||||
className={styles.table}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_LIST_COLUMNS}
|
||||
respectColumnOrder={false}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
isLoading={isFetching}
|
||||
getRowKey={getTraceRowKey}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
disableVirtualScroll
|
||||
testId="ai-observability-list-view-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
@@ -1,111 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Older callers passed `{ key, type }` where v5 uses `{ name, fieldContext }`. */
|
||||
interface LegacyFieldKey {
|
||||
key?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const BADGE_FIELDS = new Set([
|
||||
'httpMethod',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
'http_method',
|
||||
]);
|
||||
|
||||
const DURATION_FIELDS = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
const TIMESTAMP_COLUMN_ID = 'date';
|
||||
|
||||
/** Cells must tolerate missing values: skeleton rows pass through them. */
|
||||
export function useListTableColumns(
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
): TableColumnDef<TraceListRow>[] {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
return useMemo<TableColumnDef<TraceListRow>[]>(() => {
|
||||
const timestampColumn: TableColumnDef<TraceListRow> = {
|
||||
id: TIMESTAMP_COLUMN_ID,
|
||||
header: 'Timestamp',
|
||||
accessorFn: (row): unknown => row?.date,
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
width: { default: 180, min: 180 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const timestamp = value as string | number | undefined;
|
||||
if (timestamp === undefined || timestamp === null) {
|
||||
return <TanStackTable.Text> </TanStackTable.Text>;
|
||||
}
|
||||
const formatted =
|
||||
typeof timestamp === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(
|
||||
timestamp,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
timestamp / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
return <TanStackTable.Text>{String(formatted)}</TanStackTable.Text>;
|
||||
},
|
||||
};
|
||||
|
||||
const fieldColumns = selectedColumns.map(
|
||||
(field): TableColumnDef<TraceListRow> => {
|
||||
const legacy = field as TelemetryFieldKey & LegacyFieldKey;
|
||||
const name = field?.name || legacy?.key || '';
|
||||
const fieldContext = field?.fieldContext || legacy?.type;
|
||||
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row?.[name],
|
||||
enableRemove: false,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return <TanStackTable.Text data-testid={name}>N/A</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
if (BADGE_FIELDS.has(name)) {
|
||||
return (
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{formatCellValue(value)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (DURATION_FIELDS.has(name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>
|
||||
{getMs(formatCellValue(value))}ms
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span data-testid={name}>
|
||||
<LineClampedText text={formatCellValue(value)} lines={3} />
|
||||
</span>
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return [timestampColumn, ...fieldColumns];
|
||||
}, [selectedColumns, formatTimezoneAdjustedTimestamp]);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { formUrlParams } from 'container/TraceDetail/utils';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Rows carry the span's attributes plus `date` (the list item's timestamp). */
|
||||
export const transformDataWithDate = (data: QueryDataV3[]): TraceListRow[] =>
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: TraceListRow): string =>
|
||||
`${ROUTES.TRACE}/${formatCellValue(record.traceID || record.trace_id)}${formUrlParams(
|
||||
{
|
||||
spanId: record.spanID || record.span_id,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
},
|
||||
)}`;
|
||||
|
||||
/** Row identity: span id per row, trace id as the root-only fallback. */
|
||||
export const getTraceRowKey = (record: TraceListRow): string =>
|
||||
formatCellValue(
|
||||
record?.spanID ?? record?.span_id ?? record?.traceID ?? record?.trace_id,
|
||||
);
|
||||
@@ -1,61 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
version="v3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(QuerySection);
|
||||
@@ -1,7 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: var(--spacing-6);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Space } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import styles from './TableView.module.scss';
|
||||
|
||||
function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap.traces,
|
||||
graphType: panelType || PANEL_TYPES.TABLE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableData = useMemo(
|
||||
() =>
|
||||
data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload.data.result ||
|
||||
[],
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
<div className={styles.header}>
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
<QueryTable
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
queryTableData={queryTableData as QueryDataV3[]}
|
||||
loading={isLoading}
|
||||
sticky
|
||||
/>
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
@@ -1,145 +0,0 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
function TimeSeriesViewContainer({
|
||||
dataSource = DataSource.TRACES,
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap[dataSource],
|
||||
graphType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimeSeriesViewProps {
|
||||
dataSource?: DataSource;
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
@@ -1,19 +0,0 @@
|
||||
.loadingTraces {
|
||||
padding: var(--spacing-12) 0;
|
||||
height: 240px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gif {
|
||||
height: 72px;
|
||||
margin-left: calc(var(--spacing-12) * -1);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import styles from './TraceLoading.module.scss';
|
||||
|
||||
export function TracesLoading(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loadingTraces}>
|
||||
<div className={styles.content}>
|
||||
<img className={styles.gif} src={loadingPlaneUrl} alt="wait-icon" />
|
||||
|
||||
<Typography>Retrieving your traces!</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.table {
|
||||
max-height: calc(100vh - 330px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { PER_PAGE_OPTIONS } from '../constants';
|
||||
import ExplorerControls from '../Controls/Controls';
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { TraceListRow } from '../tableUtils';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
import { getRootSpanRowKey } from './utils';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const tableData = useMemo(
|
||||
(): TraceListRow[] => responseData?.map((listItem) => listItem.data) ?? [],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && tableData.length !== 0) {
|
||||
void logEvent('AI Observability Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{tableData.length !== 0 && (
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className={styles.controls}>
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<ExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && tableData.length === 0)) && <TracesLoading />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
tableData.length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
tableData.length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{tableData.length !== 0 && (
|
||||
<TanStackTable<TraceListRow>
|
||||
data={tableData}
|
||||
columns={columns}
|
||||
className={styles.table}
|
||||
columnStorageKey={LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS}
|
||||
isLoading={isLoading}
|
||||
getRowKey={getRootSpanRowKey}
|
||||
disableVirtualScroll
|
||||
testId="ai-observability-traces-view-table"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
@@ -1,77 +0,0 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Fixed root-span columns — no user selection, so nothing can be removed. */
|
||||
export const columns: TableColumnDef<TraceListRow>[] = [
|
||||
{
|
||||
id: 'serviceName',
|
||||
header: 'Root Service Name',
|
||||
accessorFn: (row): unknown => row?.['service.name'],
|
||||
enableRemove: false,
|
||||
width: { min: 200 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Root Operation Name',
|
||||
accessorFn: (row): unknown => row?.name,
|
||||
enableRemove: false,
|
||||
width: { min: 260 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'durationNano',
|
||||
header: 'Root Duration (in ms)',
|
||||
accessorFn: (row): unknown => row?.duration_nano,
|
||||
enableRemove: false,
|
||||
width: { min: 170 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>
|
||||
{value === undefined || value === null
|
||||
? ''
|
||||
: `${getMs(formatCellValue(value))}ms`}
|
||||
</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'span_count',
|
||||
header: 'No of Spans',
|
||||
accessorFn: (row): unknown => row?.span_count,
|
||||
enableRemove: false,
|
||||
width: { min: 120 },
|
||||
cell: ({ value }): ReactElement => (
|
||||
<TanStackTable.Text>{formatCellValue(value)}</TanStackTable.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'traceID',
|
||||
header: 'TraceID',
|
||||
accessorFn: (row): unknown => row?.trace_id,
|
||||
enableRemove: false,
|
||||
width: { min: 290 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const traceID = formatCellValue(value);
|
||||
if (!traceID) {
|
||||
return <TanStackTable.Text> </TanStackTable.Text>;
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: traceID })}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,5 +0,0 @@
|
||||
import { formatCellValue, TraceListRow } from '../tableUtils';
|
||||
|
||||
/** Row identity for root spans: one row per trace; tolerates skeleton rows. */
|
||||
export const getRootSpanRowKey = (record: TraceListRow): string =>
|
||||
formatCellValue(record?.trace_id ?? record?.traceID);
|
||||
@@ -1,50 +0,0 @@
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
show: true,
|
||||
key: 'list',
|
||||
},
|
||||
timeseries: {
|
||||
name: 'timeseries',
|
||||
label: 'Timeseries',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'table',
|
||||
},
|
||||
clickhouse: {
|
||||
name: 'clickhouse',
|
||||
label: 'Clickhouse',
|
||||
disabled: false,
|
||||
show: false,
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
|
||||
//TODO: Change this later
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = DEFAULT_PER_PAGE_OPTIONS;
|
||||
@@ -1,75 +0,0 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const getListViewQuery = (
|
||||
stagedQuery: Query,
|
||||
orderBy?: string,
|
||||
): Query => {
|
||||
const query = stagedQuery
|
||||
? cloneDeep(stagedQuery)
|
||||
: cloneDeep(initialQueriesMap.traces);
|
||||
|
||||
const orderByPayload: OrderByPayload[] = orderBy
|
||||
? [
|
||||
{
|
||||
columnName: orderBy.split(':')[0],
|
||||
order: orderBy.split(':')[1] as 'asc' | 'desc',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
for (let i = 0; i < query.builder.queryData.length; i++) {
|
||||
const queryData = query.builder.queryData[i];
|
||||
queryData.groupBy = [];
|
||||
queryData.having = {
|
||||
expression: '',
|
||||
};
|
||||
queryData.orderBy = orderByPayload;
|
||||
}
|
||||
|
||||
if (
|
||||
query.builder.queryTraceOperator &&
|
||||
query.builder.queryTraceOperator.length > 0
|
||||
) {
|
||||
for (let i = 0; i < query.builder.queryTraceOperator.length; i++) {
|
||||
const queryTraceOperator = query.builder.queryTraceOperator[i];
|
||||
queryTraceOperator.groupBy = [];
|
||||
queryTraceOperator.having = {
|
||||
expression: '',
|
||||
};
|
||||
queryTraceOperator.orderBy = orderByPayload;
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const getQueryByPanelType = (
|
||||
stagedQuery: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
|
||||
return getListViewQuery(stagedQuery);
|
||||
}
|
||||
return stagedQuery;
|
||||
};
|
||||
|
||||
export const getExportQueryData = (
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
options: OptionsQuery,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const updatedQuery = cloneDeep(query);
|
||||
set(
|
||||
updatedQuery,
|
||||
'builder.queryData[0].selectColumns',
|
||||
options.selectColumns,
|
||||
);
|
||||
|
||||
return updatedQuery;
|
||||
}
|
||||
return query;
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
/** Span attributes, flattened. Every field access must tolerate undefined. */
|
||||
export type TraceListRow = Record<string, unknown>;
|
||||
|
||||
/** Renders a row value as text; objects are JSON-serialised. */
|
||||
export const formatCellValue = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -18,12 +18,6 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
|
||||
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
|
||||
}));
|
||||
|
||||
// Same for the Explorer tab, which renders a full query-builder surface.
|
||||
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
|
||||
@@ -124,6 +124,9 @@ function Application(): JSX.Element {
|
||||
start: minTime,
|
||||
end: maxTime,
|
||||
}),
|
||||
// the time range is part of the key, so without this every window change blanks the
|
||||
// operations list and the widgets below are rebuilt with an empty `operation in []`
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const selectedTraceTags: string = JSON.stringify(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import useBaseAggregateOptions from '../useBaseAggregateOptions';
|
||||
|
||||
const mockGetUpdatedQuery = jest.fn();
|
||||
const mockNotificationsError = jest.fn();
|
||||
|
||||
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
getUpdatedQuery: mockGetUpdatedQuery,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): unknown => ({
|
||||
notifications: { error: mockNotificationsError },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/dashboard/useContextVariables', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({ processedVariables: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): unknown => ({ safeNavigate: jest.fn() }),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({ pathname: '/services/socky-api' }),
|
||||
}));
|
||||
|
||||
const QUERY = {
|
||||
builder: {
|
||||
queryData: [{ queryName: 'A', dataSource: 'traces', aggregations: [] }],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const AGGREGATE_DATA = { queryName: 'A', filters: [] };
|
||||
|
||||
const renderOptions = (): ReturnType<typeof renderHook> =>
|
||||
renderHook(() =>
|
||||
useBaseAggregateOptions({
|
||||
query: QUERY,
|
||||
onClose: jest.fn(),
|
||||
subMenu: '',
|
||||
setSubMenu: jest.fn(),
|
||||
aggregateData: AGGREGATE_DATA,
|
||||
fieldVariables: {},
|
||||
}),
|
||||
);
|
||||
|
||||
describe('useBaseAggregateOptions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('notifies and keeps the unresolved query when variable resolution fails', async () => {
|
||||
mockGetUpdatedQuery.mockRejectedValue(
|
||||
new Error('syntax errors in expression'),
|
||||
);
|
||||
|
||||
renderOptions();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockNotificationsError).toHaveBeenCalledWith({
|
||||
message: 'Unable to resolve variables',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not notify when variable resolution succeeds', async () => {
|
||||
mockGetUpdatedQuery.mockResolvedValue(QUERY);
|
||||
|
||||
renderOptions();
|
||||
|
||||
await waitFor(() => expect(mockGetUpdatedQuery).toHaveBeenCalled());
|
||||
expect(mockNotificationsError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
|
||||
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
|
||||
import useContextVariables from 'hooks/dashboard/useContextVariables';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import ContextMenu from 'periscope/components/ContextMenu';
|
||||
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { ContextLinksData } from 'types/api/dashboard/getAll';
|
||||
@@ -50,23 +51,25 @@ const useBaseAggregateOptions = ({
|
||||
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
|
||||
useUpdatedQuery();
|
||||
const { dashboardData } = useDashboardStore();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
if (!aggregateData) {
|
||||
return;
|
||||
}
|
||||
const resolveQuery = async (): Promise<void> => {
|
||||
const updatedQuery = await getUpdatedQuery({
|
||||
widgetConfig: {
|
||||
query,
|
||||
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
},
|
||||
dashboardData,
|
||||
getUpdatedQuery({
|
||||
widgetConfig: {
|
||||
query,
|
||||
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
},
|
||||
dashboardData,
|
||||
})
|
||||
.then(setResolvedQuery)
|
||||
.catch(() => {
|
||||
setResolvedQuery(query);
|
||||
notifications.error({ message: 'Unable to resolve variables' });
|
||||
});
|
||||
setResolvedQuery(updatedQuery);
|
||||
};
|
||||
resolveQuery();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, aggregateData, panelType]);
|
||||
|
||||
|
||||
@@ -690,7 +690,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
urlQuery.set('spanId', span?.span_id);
|
||||
}
|
||||
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
},
|
||||
[setSelectedSpan, urlQuery, safeNavigate],
|
||||
);
|
||||
|
||||
@@ -260,11 +260,13 @@ 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'),
|
||||
});
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringContaining('spanId=span-1'),
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('clicking span duration visually selects the span', async () => {
|
||||
@@ -430,10 +432,13 @@ 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/,
|
||||
),
|
||||
});
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringMatching(
|
||||
/existingParam=existingValue.*anotherParam=anotherValue.*spanId=span-1/,
|
||||
),
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() });
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
|
||||
@@ -223,9 +223,7 @@ func TestEmailNotifyWithErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
c, err := loadEmailTestConfiguration(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
@@ -288,11 +286,6 @@ 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,
|
||||
@@ -309,15 +302,15 @@ func TestEmailNotifyWithErrors(t *testing.T) {
|
||||
|
||||
_, retry, err := notifyEmail(t, emailCfg, c.Server)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
require.False(t, retry)
|
||||
assert.Contains(t, err.Error(), tc.errMsg)
|
||||
assert.False(t, retry)
|
||||
|
||||
e, err := c.Server.getLastEmail(t)
|
||||
require.NoError(t, err)
|
||||
if tc.hasEmail {
|
||||
require.NotNil(t, e)
|
||||
assert.NotNil(t, e)
|
||||
} else {
|
||||
require.Nil(t, e)
|
||||
assert.Nil(t, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -331,9 +324,7 @@ func TestEmailNotifyWithDoneContext(t *testing.T) {
|
||||
}
|
||||
|
||||
c, err := loadEmailTestConfiguration(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -350,7 +341,7 @@ func TestEmailNotifyWithDoneContext(t *testing.T) {
|
||||
c.Server,
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "establish connection to server")
|
||||
assert.Contains(t, err.Error(), "establish connection to server")
|
||||
}
|
||||
|
||||
// TestEmailNotifyWithoutAuthentication sends an email to an instance of
|
||||
@@ -363,9 +354,7 @@ func TestEmailNotifyWithoutAuthentication(t *testing.T) {
|
||||
}
|
||||
|
||||
c, err := loadEmailTestConfiguration(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
mail, _, err := notifyEmail(
|
||||
t,
|
||||
@@ -390,7 +379,7 @@ func TestEmailNotifyWithoutAuthentication(t *testing.T) {
|
||||
}
|
||||
headers = append(headers, k)
|
||||
}
|
||||
require.True(t, foundMsgID, "Couldn't find 'message-id' in %v", headers)
|
||||
assert.True(t, foundMsgID, "Couldn't find 'message-id' in %v", headers)
|
||||
}
|
||||
|
||||
// TestEmailNotifyWithSTARTTLS connects to the server, upgrades the connection
|
||||
@@ -406,9 +395,7 @@ func TestEmailNotifyWithSTARTTLS(t *testing.T) {
|
||||
}
|
||||
|
||||
c, err := loadEmailTestConfiguration(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
trueVar := true
|
||||
_, _, err = notifyEmail(
|
||||
@@ -437,9 +424,7 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
|
||||
}
|
||||
|
||||
c, err := loadEmailTestConfiguration(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
td := t.TempDir()
|
||||
fileWithCorrectPassword, err := os.CreateTemp(td, "smtp-password-correct")
|
||||
@@ -583,13 +568,13 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
|
||||
e, retry, err := notifyEmail(t, emailCfg, c.Server)
|
||||
if len(tc.errMsg) > 0 {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
require.Equal(t, tc.retry, retry)
|
||||
assert.Contains(t, err.Error(), tc.errMsg)
|
||||
assert.Equal(t, tc.retry, retry)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "1 firing alert(s)", e.Subject)
|
||||
assert.Equal(t, "1 firing alert(s)", e.Subject)
|
||||
|
||||
getAddresses := func(addresses []map[string]string) []string {
|
||||
res := make([]string, 0, len(addresses))
|
||||
@@ -600,19 +585,21 @@ func TestEmailNotifyWithAuthentication(t *testing.T) {
|
||||
}
|
||||
to := getAddresses(e.To)
|
||||
from := getAddresses(e.From)
|
||||
require.Equal(t, strings.Split(emailCfg.To, ","), to)
|
||||
require.Equal(t, strings.Split(emailCfg.From, ","), from)
|
||||
assert.Equal(t, strings.Split(emailCfg.To, ","), to)
|
||||
assert.Equal(t, strings.Split(emailCfg.From, ","), from)
|
||||
|
||||
if len(emailCfg.HTML) > 0 {
|
||||
require.Equal(t, emailCfg.HTML, *e.HTML)
|
||||
require.NotNil(t, e.HTML)
|
||||
assert.Equal(t, emailCfg.HTML, *e.HTML)
|
||||
} else {
|
||||
require.Nil(t, e.HTML)
|
||||
assert.Nil(t, e.HTML)
|
||||
}
|
||||
|
||||
if len(emailCfg.Text) > 0 {
|
||||
require.Equal(t, emailCfg.Text, *e.Text)
|
||||
require.NotNil(t, e.Text)
|
||||
assert.Equal(t, emailCfg.Text, *e.Text)
|
||||
} else {
|
||||
require.Nil(t, e.Text)
|
||||
assert.Nil(t, e.Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -624,7 +611,7 @@ func TestEmailConfigNoAuthMechs(t *testing.T) {
|
||||
}
|
||||
_, err := email.auth("")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "unknown auth mechanism: ", err.Error())
|
||||
assert.Equal(t, "unknown auth mechanism: ", err.Error())
|
||||
}
|
||||
|
||||
func TestEmailConfigMissingAuthParam(t *testing.T) {
|
||||
@@ -634,19 +621,19 @@ func TestEmailConfigMissingAuthParam(t *testing.T) {
|
||||
}
|
||||
_, err := email.auth("CRAM-MD5")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "missing secret for CRAM-MD5 auth mechanism", err.Error())
|
||||
assert.Equal(t, "missing secret for CRAM-MD5 auth mechanism", err.Error())
|
||||
|
||||
_, err = email.auth("PLAIN")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "missing password for PLAIN auth mechanism", err.Error())
|
||||
assert.Equal(t, "missing password for PLAIN auth mechanism", err.Error())
|
||||
|
||||
_, err = email.auth("LOGIN")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "missing password for LOGIN auth mechanism", err.Error())
|
||||
assert.Equal(t, "missing password for LOGIN auth mechanism", err.Error())
|
||||
|
||||
_, err = email.auth("PLAIN LOGIN")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "missing password for PLAIN auth mechanism\nmissing password for LOGIN auth mechanism", err.Error())
|
||||
assert.Equal(t, "missing password for PLAIN auth mechanism\nmissing password for LOGIN auth mechanism", err.Error())
|
||||
}
|
||||
|
||||
func TestEmailNoUsernameCustomError(t *testing.T) {
|
||||
@@ -655,7 +642,7 @@ func TestEmailNoUsernameCustomError(t *testing.T) {
|
||||
}
|
||||
a, err := email.auth("CRAM-MD5")
|
||||
require.ErrorIs(t, err, errNoAuthUsernameConfigured)
|
||||
require.Nil(t, a)
|
||||
assert.Nil(t, a)
|
||||
}
|
||||
|
||||
// TestEmailRejected simulates the failure of an otherwise valid message submission which fails at a later point than
|
||||
@@ -720,7 +707,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!")
|
||||
require.True(t, retry)
|
||||
assert.True(t, retry)
|
||||
require.NoError(t, srv.Shutdown(ctx))
|
||||
|
||||
require.Eventuallyf(t, func() bool {
|
||||
@@ -789,9 +776,7 @@ func TestEmailNotifyWithThreading(t *testing.T) {
|
||||
}
|
||||
|
||||
c, err := loadEmailTestConfiguration(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -836,22 +821,22 @@ func TestEmailNotifyWithThreading(t *testing.T) {
|
||||
referencesValue := mail.Headers["references"]
|
||||
inReplyToValue := mail.Headers["in-reply-to"]
|
||||
|
||||
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.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.Equal(t, referencesValue, inReplyToValue, "References and In-Reply-To should match")
|
||||
assert.Equal(t, referencesValue, inReplyToValue, "References and In-Reply-To should match")
|
||||
|
||||
// Verify the format: <alert-HASH-DATE@alertmanager>
|
||||
require.Contains(t, referencesValue, "<alert-")
|
||||
require.Contains(t, referencesValue, "@alertmanager>")
|
||||
assert.Contains(t, referencesValue, "<alert-")
|
||||
assert.Contains(t, referencesValue, "@alertmanager>")
|
||||
|
||||
if tc.wantDatePart {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
require.Contains(t, referencesValue, today, "threading header should contain today's date")
|
||||
assert.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 @).
|
||||
require.Contains(t, referencesValue, "-@alertmanager>", "threading header should have empty date part")
|
||||
assert.Contains(t, referencesValue, "-@alertmanager>", "threading header should have empty date part")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -904,14 +889,14 @@ func TestEmailGetPassword(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
if errors.Asc(err, errors.CodeInternal) {
|
||||
_, _, errMsg, _, _, _ := errors.Unwrapb(err)
|
||||
require.Contains(t, errMsg, tc.errMsg)
|
||||
assert.Contains(t, errMsg, tc.errMsg)
|
||||
} else {
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
assert.Contains(t, err.Error(), tc.errMsg)
|
||||
}
|
||||
require.Empty(t, password)
|
||||
assert.Empty(t, password)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "secret", password)
|
||||
assert.Equal(t, "secret", password)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -962,11 +947,11 @@ func TestEmailGetSecret(t *testing.T) {
|
||||
secret, err := email.getAuthSecret()
|
||||
if len(tc.errMsg) > 0 {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
require.Empty(t, secret)
|
||||
assert.Contains(t, err.Error(), tc.errMsg)
|
||||
assert.Empty(t, secret)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "secret", secret)
|
||||
assert.Equal(t, "secret", secret)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1032,7 +1017,7 @@ func TestEmailImplicitTLS(t *testing.T) {
|
||||
useImplicitTLS = cfg.Smarthost.Port == "465"
|
||||
}
|
||||
|
||||
require.Equal(t, tt.expectImplicit, useImplicitTLS,
|
||||
assert.Equal(t, tt.expectImplicit, useImplicitTLS,
|
||||
"Expected useImplicitTLS=%v for port=%s with forceImplicitTLS=%v",
|
||||
tt.expectImplicit, tt.port, tt.forceImplicitTLS)
|
||||
})
|
||||
@@ -1074,8 +1059,8 @@ func TestPrepareContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "subj", subject)
|
||||
require.Equal(t, "<div><p>line one</p>\n</div><div><p>line two</p>\n</div>", htmlBody)
|
||||
assert.Equal(t, "subj", subject)
|
||||
assert.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) {
|
||||
@@ -1103,8 +1088,8 @@ func TestPrepareContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Status: firing", htmlBody)
|
||||
require.Equal(t, "fixed from firing", subject)
|
||||
assert.Equal(t, "Status: firing", htmlBody)
|
||||
assert.Equal(t, "fixed from firing", subject)
|
||||
})
|
||||
|
||||
t.Run("default template without HTML", func(t *testing.T) {
|
||||
@@ -1125,8 +1110,8 @@ func TestPrepareContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", htmlBody)
|
||||
require.Equal(t, "the email subject", subject)
|
||||
assert.Equal(t, "", htmlBody)
|
||||
assert.Equal(t, "the email subject", subject)
|
||||
})
|
||||
|
||||
t.Run("custom title template; custom body template", func(t *testing.T) {
|
||||
@@ -1160,11 +1145,11 @@ func TestPrepareContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
subject, htmlBody, err := n.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
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")
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ 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"
|
||||
@@ -54,7 +55,7 @@ func TestMSTeamsV2Retry(t *testing.T) {
|
||||
|
||||
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "retry - error on status %d", statusCode)
|
||||
assert.Equal(t, expected, actual, "retry - error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +111,7 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
} else {
|
||||
var reasonError *notify.ErrorWithReason
|
||||
require.ErrorAs(t, err, &reasonError)
|
||||
require.Equal(t, tt.expectedReason, reasonError.Reason)
|
||||
assert.Equal(t, tt.expectedReason, reasonError.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -133,7 +134,6 @@ func TestMSTeamsV2Templating(t *testing.T) {
|
||||
cfg *config.MSTeamsV2Config
|
||||
titleLink string
|
||||
|
||||
retry bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
@@ -143,7 +143,6 @@ func TestMSTeamsV2Templating(t *testing.T) {
|
||||
Text: `{{ template "msteams.default.text" . }}`,
|
||||
},
|
||||
titleLink: `{{ template "msteamsv2.default.titleLink" . }}`,
|
||||
retry: false,
|
||||
},
|
||||
{
|
||||
title: "title with templating errors",
|
||||
@@ -185,12 +184,12 @@ func TestMSTeamsV2Templating(t *testing.T) {
|
||||
},
|
||||
}...)
|
||||
if tc.errMsg == "" {
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
assert.Contains(t, err.Error(), tc.errMsg)
|
||||
}
|
||||
require.Equal(t, tc.retry, ok)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -250,14 +249,14 @@ func TestPrepareContent(t *testing.T) {
|
||||
}
|
||||
blocks, err := notifier.prepareContent(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, blocks)
|
||||
require.Len(t, blocks, 2)
|
||||
// First block should be the title with color (firing = red)
|
||||
require.Equal(t, "Bolder", blocks[0].Weight)
|
||||
require.Equal(t, colorRed, blocks[0].Color)
|
||||
assert.Equal(t, "Bolder", blocks[0].Weight)
|
||||
assert.Equal(t, colorRed, blocks[0].Color)
|
||||
// verify title text
|
||||
require.Equal(t, "Alertname: test", blocks[0].Text)
|
||||
assert.Equal(t, "Alertname: test", blocks[0].Text)
|
||||
// verify body text
|
||||
require.Equal(t, "Firing alert: test", blocks[1].Text)
|
||||
assert.Equal(t, "Firing alert: test", blocks[1].Text)
|
||||
})
|
||||
|
||||
t.Run("custom template - per-alert color", func(t *testing.T) {
|
||||
@@ -305,16 +304,15 @@ 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.True(t, len(blocks) == 3)
|
||||
require.Len(t, blocks, 3)
|
||||
// First block: title color is overall color of the alerts
|
||||
require.Equal(t, colorRed, blocks[0].Color)
|
||||
assert.Equal(t, colorRed, blocks[0].Color)
|
||||
// verify title text
|
||||
require.Equal(t, "Custom Title", blocks[0].Text)
|
||||
assert.Equal(t, "Custom Title", blocks[0].Text)
|
||||
// Body blocks should have per-alert color
|
||||
require.Equal(t, colorRed, blocks[1].Color) // firing
|
||||
require.Equal(t, colorGreen, blocks[2].Color) // resolved
|
||||
assert.Equal(t, colorRed, blocks[1].Color) // firing
|
||||
assert.Equal(t, colorGreen, blocks[2].Color) // resolved
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ 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"
|
||||
@@ -49,7 +50,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)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
assert.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +104,7 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
|
||||
|
||||
func TestOpsGenie(t *testing.T) {
|
||||
u, err := url.Parse("https://opsgenie/api")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse URL: %v", err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
logger := promslog.NewNopLogger()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
|
||||
@@ -236,10 +235,10 @@ func TestOpsGenie(t *testing.T) {
|
||||
req, retry, err := notifier.createRequests(ctx, alert1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, req, 1)
|
||||
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]))
|
||||
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]))
|
||||
|
||||
// Fully defined alert.
|
||||
alert2 := &types.Alert{
|
||||
@@ -266,15 +265,15 @@ func TestOpsGenie(t *testing.T) {
|
||||
}
|
||||
req, retry, err = notifier.createRequests(ctx, alert2)
|
||||
require.NoError(t, err)
|
||||
require.True(t, retry)
|
||||
assert.True(t, retry)
|
||||
require.Len(t, req, 1)
|
||||
require.Equal(t, tc.expectedBody, readBody(t, req[0]))
|
||||
assert.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)
|
||||
require.Equal(t, "template: :1: function \"kaput\" not defined", err.Error())
|
||||
assert.Equal(t, "template: :1: function \"kaput\" not defined", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -307,7 +306,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
requests, retry, err := notifierWithUpdate.createRequests(ctx, alert)
|
||||
require.NoError(t, err)
|
||||
require.True(t, retry)
|
||||
assert.True(t, retry)
|
||||
require.Len(t, requests, 3)
|
||||
|
||||
body0 := readBody(t, requests[0])
|
||||
@@ -316,13 +315,13 @@ func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
key, _ := notify.ExtractGroupKey(ctx)
|
||||
alias := key.Hash()
|
||||
|
||||
require.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String())
|
||||
require.NotEmpty(t, body0)
|
||||
assert.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String())
|
||||
assert.NotEmpty(t, body0)
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
@@ -341,7 +340,8 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
requests, _, err := notifierWithUpdate.createRequests(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization"))
|
||||
require.Len(t, requests, 1)
|
||||
assert.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)
|
||||
require.Equal(t, "Firing alert: test", title)
|
||||
require.Equal(t, "Check runbook for more details", desc)
|
||||
assert.Equal(t, "Firing alert: test", title)
|
||||
assert.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)
|
||||
require.Equal(t, "High request throughput for payment", title)
|
||||
assert.Equal(t, "High request throughput for payment", title)
|
||||
// Each alert body wrapped in <div>, separated by <hr>
|
||||
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)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ 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"
|
||||
@@ -54,7 +55,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)
|
||||
require.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode)
|
||||
assert.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +75,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)
|
||||
require.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode)
|
||||
assert.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,12 +350,12 @@ func TestPagerDutyTemplating(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
if errors.Asc(err, errors.CodeInternal) {
|
||||
_, _, errMsg, _, _, _ := errors.Unwrapb(err)
|
||||
require.Contains(t, errMsg, tc.errMsg)
|
||||
assert.Contains(t, errMsg, tc.errMsg)
|
||||
} else {
|
||||
require.Contains(t, err.Error(), tc.errMsg)
|
||||
assert.Contains(t, err.Error(), tc.errMsg)
|
||||
}
|
||||
}
|
||||
require.Equal(t, tc.retry, ok)
|
||||
assert.Equal(t, tc.retry, ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -393,7 +394,7 @@ func TestErrDetails(t *testing.T) {
|
||||
} {
|
||||
t.Run("", func(t *testing.T) {
|
||||
err := errDetails(tc.status, tc.body)
|
||||
require.Contains(t, err, tc.exp)
|
||||
assert.Contains(t, err, tc.exp)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -427,7 +428,7 @@ func TestEventSizeEnforcement(t *testing.T) {
|
||||
|
||||
encodedV1, err := notifierV1.encodeMessage(context.Background(), msgV1)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encodedV1.String(), `"details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
|
||||
assert.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{
|
||||
@@ -451,7 +452,7 @@ func TestEventSizeEnforcement(t *testing.T) {
|
||||
|
||||
encodedV2, err := notifierV2.encodeMessage(context.Background(), msgV2)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encodedV2.String(), `"custom_details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`)
|
||||
assert.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) {
|
||||
@@ -543,8 +544,9 @@ func TestPagerDutyEmptySrcHref(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, expectedImages, event.Images)
|
||||
require.Equal(t, expectedLinks, event.Links)
|
||||
// Handler runs on the server's goroutine — require is illegal here.
|
||||
assert.Equal(t, expectedImages, event.Images)
|
||||
assert.Equal(t, expectedLinks, event.Links)
|
||||
},
|
||||
))
|
||||
defer server.Close()
|
||||
@@ -644,7 +646,7 @@ func TestPagerDutyTimeout(t *testing.T) {
|
||||
},
|
||||
}
|
||||
_, err = pd.Notify(ctx, alert)
|
||||
require.Equal(t, tt.wantErr, err != nil)
|
||||
assert.Equal(t, tt.wantErr, err != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -899,11 +901,12 @@ func TestRenderDetails(t *testing.T) {
|
||||
tmpl: test.CreateTmpl(t),
|
||||
}
|
||||
got, err := n.renderDetails(tt.args.data)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("renderDetails() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.Equal(t, tt.want, got)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -944,7 +947,7 @@ func TestPrepareContent(t *testing.T) {
|
||||
|
||||
title, err := notifier.prepareTitle(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HighCPU for Payment service (FIRING)", title)
|
||||
assert.Equal(t, "HighCPU for Payment service (FIRING)", title)
|
||||
})
|
||||
|
||||
t.Run("custom template uses $variable annotation for title", func(t *testing.T) {
|
||||
@@ -980,6 +983,6 @@ func TestPrepareContent(t *testing.T) {
|
||||
|
||||
title, err := notifier.prepareTitle(ctx, alerts)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "HighCPU on api-server is in resolved state", title)
|
||||
assert.Equal(t, "HighCPU on api-server is in resolved state", title)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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 +51,7 @@ func TestSlackRetry(t *testing.T) {
|
||||
|
||||
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
|
||||
actual, _ := notifier.retrier.Check(statusCode, nil)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
assert.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,15 +233,15 @@ func TestNotifier_Notify_WithReason(t *testing.T) {
|
||||
},
|
||||
}
|
||||
retry, err := notifier.Notify(ctx, alert1)
|
||||
require.Equal(t, tt.expectedRetry, retry)
|
||||
assert.Equal(t, tt.expectedRetry, retry)
|
||||
if tt.noError {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
var reasonError *notify.ErrorWithReason
|
||||
require.ErrorAs(t, err, &reasonError)
|
||||
require.Equal(t, tt.expectedReason, reasonError.Reason)
|
||||
require.Contains(t, err.Error(), tt.expectedErr)
|
||||
require.Contains(t, err.Error(), "channelname")
|
||||
assert.Equal(t, tt.expectedReason, reasonError.Reason)
|
||||
assert.Contains(t, err.Error(), tt.expectedErr)
|
||||
assert.Contains(t, err.Error(), "channelname")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -296,7 +297,7 @@ func TestSlackTimeout(t *testing.T) {
|
||||
},
|
||||
}
|
||||
_, err = notifier.Notify(ctx, alert)
|
||||
require.Equal(t, tt.wantErr, err != nil)
|
||||
assert.Equal(t, tt.wantErr, err != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -350,14 +351,14 @@ func TestPrepareContent(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, atts, 1)
|
||||
|
||||
require.Equal(t, "HighCPU (FIRING)", atts[0].Title)
|
||||
require.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
|
||||
assert.Equal(t, "HighCPU (FIRING)", atts[0].Title)
|
||||
assert.Equal(t, "Alert: HighCPU - severity critical", atts[0].Text)
|
||||
// Color is templated — firing alert should be "danger"
|
||||
require.Equal(t, "danger", atts[0].Color)
|
||||
assert.Equal(t, "danger", atts[0].Color)
|
||||
// No BlockKit blocks for default template
|
||||
require.Nil(t, atts[0].Blocks)
|
||||
assert.Nil(t, atts[0].Blocks)
|
||||
// Default markdownIn when config has none
|
||||
require.Equal(t, []string{"fallback", "pretext", "text"}, atts[0].MrkdwnIn)
|
||||
assert.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) {
|
||||
@@ -428,10 +429,10 @@ func TestPrepareContent(t *testing.T) {
|
||||
require.Len(t, atts, 3)
|
||||
|
||||
// First attachment: title-only, no color, no blocks
|
||||
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)
|
||||
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)
|
||||
|
||||
expectedFiringBody := "*HighCPU*\n\n" +
|
||||
"*Service:* _api-server_\n*Instance:* _i-0abc123_\n*Region:* _us-east-1_\n*Method:* _GET_\n\n" +
|
||||
@@ -446,16 +447,16 @@ func TestPrepareContent(t *testing.T) {
|
||||
"*Status:* resolved | *Severity:* critical\n\n"
|
||||
|
||||
// Second attachment: firing alert body rendered as slack mrkdwn text, red color
|
||||
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)
|
||||
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)
|
||||
|
||||
// Third attachment: resolved alert body rendered as slack mrkdwn text, green color
|
||||
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)
|
||||
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)
|
||||
})
|
||||
|
||||
t.Run("default template with fields and actions", func(t *testing.T) {
|
||||
@@ -498,49 +499,45 @@ func TestPrepareContent(t *testing.T) {
|
||||
|
||||
// prepareContent does not populate fields/actions — that's done by
|
||||
// addFieldsAndActions which is called from Notify.
|
||||
require.Nil(t, atts[0].Fields)
|
||||
require.Nil(t, atts[0].Actions)
|
||||
assert.Nil(t, atts[0].Fields)
|
||||
assert.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)
|
||||
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)
|
||||
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)
|
||||
|
||||
// Verify actions
|
||||
require.Len(t, atts[0].Actions, 1)
|
||||
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)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlackMessageField(t *testing.T) {
|
||||
// 1. Setup a fake Slack server
|
||||
// 1. Setup a fake Slack server. The handler runs on the server's
|
||||
// goroutine, so only assert (never require) is safe here.
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
||||
|
||||
// 2. VERIFY: Top-level text exists
|
||||
if body["text"] != "My Top Level Message" {
|
||||
t.Errorf("Expected top-level 'text' to be 'My Top Level Message', got %v", body["text"])
|
||||
}
|
||||
assert.Equal(t, "My Top Level Message", body["text"])
|
||||
|
||||
// 3. VERIFY: Old attachments still exist
|
||||
attachments, ok := body["attachments"].([]any)
|
||||
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"])
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,21 +558,16 @@ func TestSlackMessageField(t *testing.T) {
|
||||
}
|
||||
|
||||
tmpl, err := template.FromGlobs([]string{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL = u
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(conf, tmpl, logger, newTestTemplater(tmpl))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx = notify.WithGroupKey(ctx, "test-group-key")
|
||||
|
||||
if _, err := notifier.Notify(ctx); err != nil {
|
||||
t.Fatal("Notify failed:", err)
|
||||
}
|
||||
_, err = notifier.Notify(ctx)
|
||||
require.NoError(t, err, "Notify failed")
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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"
|
||||
@@ -39,14 +40,12 @@ func TestWebhookRetry(t *testing.T) {
|
||||
promslog.NewNopLogger(),
|
||||
alertmanagertemplate.New(tmpl, slog.Default()),
|
||||
)
|
||||
if err != nil {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
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)
|
||||
require.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
assert.Equal(t, expected, actual, "error on status %d", statusCode)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -73,7 +72,8 @@ func TestWebhookRetry(t *testing.T) {
|
||||
} {
|
||||
t.Run("", func(t *testing.T) {
|
||||
_, err = notifier.retrier.Check(tc.status, tc.body)
|
||||
require.Equal(t, tc.exp, err.Error())
|
||||
require.Error(t, err)
|
||||
assert.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)
|
||||
require.Len(t, truncatedAlerts, 10)
|
||||
require.EqualValues(t, 0, numTruncated)
|
||||
assert.Len(t, truncatedAlerts, 10)
|
||||
assert.EqualValues(t, 0, numTruncated)
|
||||
|
||||
truncatedAlerts, numTruncated = truncateAlerts(4, alerts)
|
||||
require.Len(t, truncatedAlerts, 4)
|
||||
require.EqualValues(t, 6, numTruncated)
|
||||
assert.Len(t, truncatedAlerts, 4)
|
||||
assert.EqualValues(t, 6, numTruncated)
|
||||
|
||||
truncatedAlerts, numTruncated = truncateAlerts(100, alerts)
|
||||
require.Len(t, truncatedAlerts, 10)
|
||||
require.EqualValues(t, 0, numTruncated)
|
||||
assert.Len(t, truncatedAlerts, 10)
|
||||
assert.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)
|
||||
require.Contains(t, err.Error(), tc.expectedErrMsg)
|
||||
assert.Contains(t, err.Error(), tc.expectedErrMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedPath, calledURL)
|
||||
assert.Equal(t, tc.expectedPath, calledURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
51
pkg/apiserver/signozapiserver/aiobservability.go
Normal file
51
pkg/apiserver/signozapiserver/aiobservability.go
Normal file
@@ -0,0 +1,51 @@
|
||||
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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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"
|
||||
@@ -61,6 +62,7 @@ type provider struct {
|
||||
infraMonitoringHandler inframonitoring.Handler
|
||||
gatewayHandler gateway.Handler
|
||||
fieldsHandler fields.Handler
|
||||
aiObservabilityHandler aiobservability.Handler
|
||||
authzHandler authz.Handler
|
||||
rawDataExportHandler rawdataexport.Handler
|
||||
zeusHandler zeus.Handler
|
||||
@@ -97,6 +99,7 @@ func NewFactory(
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
aiObservabilityHandler aiobservability.Handler,
|
||||
authzHandler authz.Handler,
|
||||
rawDataExportHandler rawdataexport.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
@@ -136,6 +139,7 @@ func NewFactory(
|
||||
infraMonitoringHandler,
|
||||
gatewayHandler,
|
||||
fieldsHandler,
|
||||
aiObservabilityHandler,
|
||||
authzHandler,
|
||||
rawDataExportHandler,
|
||||
zeusHandler,
|
||||
@@ -177,6 +181,7 @@ func newProvider(
|
||||
infraMonitoringHandler inframonitoring.Handler,
|
||||
gatewayHandler gateway.Handler,
|
||||
fieldsHandler fields.Handler,
|
||||
aiObservabilityHandler aiobservability.Handler,
|
||||
authzHandler authz.Handler,
|
||||
rawDataExportHandler rawdataexport.Handler,
|
||||
zeusHandler zeus.Handler,
|
||||
@@ -217,6 +222,7 @@ func newProvider(
|
||||
infraMonitoringHandler: infraMonitoringHandler,
|
||||
gatewayHandler: gatewayHandler,
|
||||
fieldsHandler: fieldsHandler,
|
||||
aiObservabilityHandler: aiObservabilityHandler,
|
||||
authzHandler: authzHandler,
|
||||
rawDataExportHandler: rawDataExportHandler,
|
||||
zeusHandler: zeusHandler,
|
||||
@@ -313,6 +319,10 @@ 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
|
||||
}
|
||||
|
||||
11
pkg/modules/aiobservability/handler.go
Normal file
11
pkg/modules/aiobservability/handler.go
Normal file
@@ -0,0 +1,11 @@
|
||||
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)
|
||||
}
|
||||
105
pkg/modules/aiobservability/implaiobservability/handler.go
Normal file
105
pkg/modules/aiobservability/implaiobservability/handler.go
Normal file
@@ -0,0 +1,105 @@
|
||||
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(), ¶ms); 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(), ¶ms); 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,
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
@@ -213,18 +214,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", telemetrytypes.GenAIRequestModel)},
|
||||
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", aiobservabilitytypes.GenAIRequestModel)},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "count()", Alias: "spanCount"},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: telemetrytypes.GenAIRequestModel,
|
||||
Name: aiobservabilitytypes.GenAIRequestModel,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: telemetrytypes.GenAIProviderName,
|
||||
Name: aiobservabilitytypes.GenAIProviderName,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
@@ -254,9 +255,9 @@ func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]
|
||||
switch c.Type {
|
||||
case qbtypes.ColumnTypeGroup:
|
||||
switch c.Name {
|
||||
case telemetrytypes.GenAIRequestModel:
|
||||
case aiobservabilitytypes.GenAIRequestModel:
|
||||
modelIdx = i
|
||||
case telemetrytypes.GenAIProviderName:
|
||||
case aiobservabilitytypes.GenAIProviderName:
|
||||
providerIdx = i
|
||||
}
|
||||
case qbtypes.ColumnTypeAggregation:
|
||||
|
||||
@@ -23,11 +23,7 @@ func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, e
|
||||
traces uint64
|
||||
tracesLastSeenAt time.Time
|
||||
)
|
||||
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 {
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), max(timestamp) FROM %s", tracesTable)).Scan(&traces, &tracesLastSeenAt); err == nil {
|
||||
stats["telemetry.traces.count"] = traces
|
||||
if tracesLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.traces.last_observed.time"] = tracesLastSeenAt.UTC()
|
||||
@@ -41,11 +37,7 @@ func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, e
|
||||
logs uint64
|
||||
logsLastSeenAt time.Time
|
||||
)
|
||||
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 {
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), fromUnixTimestamp64Nano(max(timestamp)) FROM %s", logsTable)).Scan(&logs, &logsLastSeenAt); err == nil {
|
||||
stats["telemetry.logs.count"] = logs
|
||||
if logsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.logs.last_observed.time"] = logsLastSeenAt.UTC()
|
||||
@@ -59,11 +51,7 @@ func (q *querier) Collect(ctx context.Context, _ valuer.UUID) (map[string]any, e
|
||||
metrics uint64
|
||||
metricsLastSeenAt time.Time
|
||||
)
|
||||
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 {
|
||||
if err := q.telemetryStore.ClickhouseDB().QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*), toDateTime(max(unix_milli) / 1000) FROM %s", metricsTable)).Scan(&metrics, &metricsLastSeenAt); err == nil {
|
||||
stats["telemetry.metrics.count"] = metrics
|
||||
if metricsLastSeenAt.Unix() != 0 {
|
||||
stats["telemetry.metrics.last_observed.time"] = metricsLastSeenAt.UTC()
|
||||
@@ -75,12 +63,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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"
|
||||
@@ -72,6 +74,7 @@ type Handlers struct {
|
||||
FlaggerHandler flagger.Handler
|
||||
GatewayHandler gateway.Handler
|
||||
Fields fields.Handler
|
||||
AIObservability aiobservability.Handler
|
||||
AuthzHandler authz.Handler
|
||||
ZeusHandler zeus.Handler
|
||||
QuerierHandler querier.Handler
|
||||
@@ -120,6 +123,7 @@ 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,
|
||||
|
||||
@@ -17,6 +17,7 @@ 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"
|
||||
@@ -74,6 +75,7 @@ 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 }{},
|
||||
|
||||
@@ -240,6 +240,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
|
||||
sqlmigration.NewBackfillSavedViewRequestTypeFactory(sqlstore),
|
||||
sqlmigration.NewRestructureAuthDomainConfigFactory(sqlstore),
|
||||
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -325,6 +326,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.InfraMonitoring,
|
||||
handlers.GatewayHandler,
|
||||
handlers.Fields,
|
||||
handlers.AIObservability,
|
||||
handlers.AuthzHandler,
|
||||
handlers.RawDataExport,
|
||||
handlers.ZeusHandler,
|
||||
|
||||
224
pkg/sqlmigration/114_fix_saved_view_select_fields.go
Normal file
224
pkg/sqlmigration/114_fix_saved_view_select_fields.go
Normal file
@@ -0,0 +1,224 @@
|
||||
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
|
||||
}
|
||||
@@ -7,7 +7,9 @@ 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"
|
||||
)
|
||||
@@ -25,7 +27,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{telemetrytypes.GenAIRequestModel, telemetrytypes.GenAIToolName, telemetrytypes.GenAIAgentName}
|
||||
gateKeyNames := []string{aiobservabilitytypes.GenAIRequestModel, aiobservabilitytypes.GenAIToolName, aiobservabilitytypes.GenAIAgentName}
|
||||
gateExprs := make([]string, 0, len(gateKeyNames))
|
||||
gateKeys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(gateKeyNames))
|
||||
for _, name := range gateKeyNames {
|
||||
@@ -37,14 +39,14 @@ func Scope() scopedtraces.TraceScope {
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
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]
|
||||
|
||||
str := telemetrytypes.FieldDataTypeString
|
||||
columns := append(scopedtraces.CommonTraceColumns(),
|
||||
@@ -62,13 +64,20 @@ 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.
|
||||
// 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.
|
||||
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,
|
||||
|
||||
@@ -11,6 +11,8 @@ 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"
|
||||
@@ -40,8 +42,7 @@ func otelKeysMap() map[string][]*telemetrytypes.TelemetryFieldKey {
|
||||
|
||||
m := make(map[string][]*telemetrytypes.TelemetryFieldKey)
|
||||
|
||||
// mirrors what enrichWithGenAIKeys surfaces in production
|
||||
for name, def := range telemetrytypes.GenAIFieldDefinitions {
|
||||
for name, def := range aitelemetryschema.GenAIFields {
|
||||
keyCopy := def
|
||||
m[name] = []*telemetrytypes.TelemetryFieldKey{&keyCopy}
|
||||
}
|
||||
@@ -976,8 +977,8 @@ func TestBuild_UnsupportedRequestType(t *testing.T) {
|
||||
// mask, OR-combined.
|
||||
func TestBuild_TraceList_MultiVariantGateKey(t *testing.T) {
|
||||
keys := otelKeysMap()
|
||||
keys[telemetrytypes.GenAIToolName] = append(keys[telemetrytypes.GenAIToolName], &telemetrytypes.TelemetryFieldKey{
|
||||
Name: telemetrytypes.GenAIToolName,
|
||||
keys[aiobservabilitytypes.GenAIToolName] = append(keys[aiobservabilitytypes.GenAIToolName], &telemetrytypes.TelemetryFieldKey{
|
||||
Name: aiobservabilitytypes.GenAIToolName,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
|
||||
@@ -24,9 +24,10 @@ 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 and the aggregate filter; all-span
|
||||
// aggregates are display-only and set false.
|
||||
Orderable bool
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -185,18 +185,19 @@ 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(), orderableSet, start, end, variables, matchedSB)
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), filterableSet, start, end, variables, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, filterableSet, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -326,9 +327,10 @@ func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID val
|
||||
}
|
||||
|
||||
type resolvedColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
orderable bool
|
||||
alias string
|
||||
expr string
|
||||
orderable bool
|
||||
filterable bool
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, cols *columnResolver, preds *predicateResolver) ([]resolvedColumn, error) {
|
||||
@@ -338,7 +340,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})
|
||||
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable, filterable: c.Filterable})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -393,7 +395,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, orderableSet 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, filterableSet 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)
|
||||
@@ -429,7 +431,7 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID val
|
||||
}
|
||||
fp.havingExpr = replaced
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, orderableSet); err != nil {
|
||||
if err := validateAggregateFilter(fp.havingExpr, filterableSet); err != nil {
|
||||
return fp, err
|
||||
}
|
||||
return fp, nil
|
||||
@@ -473,7 +475,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 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, filterableSet 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 {
|
||||
@@ -513,8 +515,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(orderableSet)*2)
|
||||
for a := range orderableSet {
|
||||
columnMap := make(map[string]string, len(filterableSet)*2)
|
||||
for a := range filterableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
|
||||
}
|
||||
@@ -603,6 +605,17 @@ 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{} {
|
||||
@@ -630,19 +643,19 @@ func traceAggregateNames(havingExpr string) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter referencing an aggregate not
|
||||
// computable in the matched pass.
|
||||
func validateAggregateFilter(havingExpr string, orderableSet map[string]struct{}) error {
|
||||
// validateAggregateFilter rejects a trace-level filter referencing an aggregate that
|
||||
// is not filterable.
|
||||
func validateAggregateFilter(havingExpr string, filterableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := make([]string, 0, len(orderableSet))
|
||||
for a := range orderableSet {
|
||||
allowed := make([]string, 0, len(filterableSet))
|
||||
for a := range filterableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; !ok {
|
||||
if _, ok := filterableSet[name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
}
|
||||
|
||||
@@ -1168,27 +1168,6 @@ 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
|
||||
@@ -1274,9 +1253,6 @@ 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
|
||||
}
|
||||
@@ -1355,9 +1331,6 @@ 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
|
||||
}
|
||||
|
||||
58
pkg/telemetryschema/aitelemetryschema/const.go
Normal file
58
pkg/telemetryschema/aitelemetryschema/const.go
Normal file
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
26
pkg/telemetryschema/aitelemetryschema/field_keys.go
Normal file
26
pkg/telemetryschema/aitelemetryschema/field_keys.go
Normal file
@@ -0,0 +1,26 @@
|
||||
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
|
||||
}
|
||||
46
pkg/types/aiobservabilitytypes/field.go
Normal file
46
pkg/types/aiobservabilitytypes/field.go
Normal file
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
28
pkg/types/aiobservabilitytypes/keys.go
Normal file
28
pkg/types/aiobservabilitytypes/keys.go
Normal file
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
)
|
||||
@@ -15,11 +15,6 @@ 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 (
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"bytes"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -84,21 +84,21 @@ func buildProcessorConfig(rules []*LLMPricingRule) *LLMPricingRuleProcessorConfi
|
||||
|
||||
return &LLMPricingRuleProcessorConfig{
|
||||
Attrs: LLMPricingRuleProcessorAttrs{
|
||||
Model: telemetrytypes.GenAIRequestModel,
|
||||
In: telemetrytypes.GenAIUsageInputTokens,
|
||||
Out: telemetrytypes.GenAIUsageOutputTokens,
|
||||
CacheRead: telemetrytypes.GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: telemetrytypes.GenAIUsageCacheCreationInputTokens,
|
||||
Model: aiobservabilitytypes.GenAIRequestModel,
|
||||
In: aiobservabilitytypes.GenAIUsageInputTokens,
|
||||
Out: aiobservabilitytypes.GenAIUsageOutputTokens,
|
||||
CacheRead: aiobservabilitytypes.GenAIUsageCacheReadInputTokens,
|
||||
CacheWrite: aiobservabilitytypes.GenAIUsageCacheCreationInputTokens,
|
||||
},
|
||||
DefaultPricing: LLMPricingRuleProcessorDefaultPricing{
|
||||
Rules: pricingRules,
|
||||
},
|
||||
OutputAttrs: LLMPricingRuleProcessorOutputAttrs{
|
||||
In: SignozGenAICostInput,
|
||||
Out: SignozGenAICostOutput,
|
||||
CacheRead: SignozGenAICostCacheRead,
|
||||
CacheWrite: SignozGenAICostCacheWrite,
|
||||
Total: telemetrytypes.SignozGenAITotalCost,
|
||||
In: aiobservabilitytypes.SignozGenAICostInput,
|
||||
Out: aiobservabilitytypes.SignozGenAICostOutput,
|
||||
CacheRead: aiobservabilitytypes.SignozGenAICostCacheRead,
|
||||
CacheWrite: aiobservabilitytypes.SignozGenAICostCacheWrite,
|
||||
Total: aiobservabilitytypes.SignozGenAITotalCost,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"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": telemetrytypes.GenAIOperationName, "dataType": "string", "type": "tag"},
|
||||
{"key": telemetrytypes.GenAIProviderName, "dataType": "string", "type": "tag"},
|
||||
{"key": telemetrytypes.GenAIRequestModel, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIOperationName, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIProviderName, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIRequestModel, "dataType": "string", "type": "tag"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": telemetrytypes.GenAIToolName, "dataType": "string", "type": "tag"},
|
||||
{"key": telemetrytypes.GenAIAgentName, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIToolName, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIAgentName, "dataType": "string", "type": "tag"},
|
||||
}
|
||||
|
||||
tracesJSON, err := json.Marshal(tracesFilters)
|
||||
|
||||
@@ -92,6 +92,11 @@ 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)...)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,17 @@ 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{
|
||||
|
||||
@@ -246,6 +246,27 @@ 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"`
|
||||
|
||||
@@ -188,7 +188,7 @@ func (FieldContext) Enum() []any {
|
||||
FieldContextMetric,
|
||||
FieldContextLog,
|
||||
FieldContextSpan,
|
||||
// FieldContextTrace,
|
||||
FieldContextTrace,
|
||||
FieldContextResource,
|
||||
// FieldContextScope,
|
||||
FieldContextAttribute,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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},
|
||||
}
|
||||
19
tests/fixtures/metadata.py
vendored
19
tests/fixtures/metadata.py
vendored
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.fingerprint import LogsOrTracesFingerprint
|
||||
@@ -106,6 +107,24 @@ 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,
|
||||
|
||||
137
tests/integration/tests/querierai/03_ai_metadata.py
Normal file
137
tests/integration/tests/querierai/03_ai_metadata.py
Normal file
@@ -0,0 +1,137 @@
|
||||
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
|
||||
Reference in New Issue
Block a user