mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 03:00:41 +01:00
Compare commits
5 Commits
feat/ai-tr
...
fix/new-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2931b5be87 | ||
|
|
6d47661cf8 | ||
|
|
707e965bc0 | ||
|
|
8ceac295aa | ||
|
|
6bdba9f949 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -68,7 +68,6 @@ jobs:
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- tracedetail
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
|
||||
@@ -1,44 +1,5 @@
|
||||
components:
|
||||
schemas:
|
||||
AiobservabilitytypesMessage:
|
||||
properties:
|
||||
content:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesPart'
|
||||
type: array
|
||||
finishReason:
|
||||
type: string
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesPart:
|
||||
properties:
|
||||
arguments: {}
|
||||
content:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
isError:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
toolCallId:
|
||||
type: string
|
||||
type:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesPartType'
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
AiobservabilitytypesPartType:
|
||||
enum:
|
||||
- text
|
||||
- thinking
|
||||
- tool_call
|
||||
- tool_result
|
||||
- generic
|
||||
type: string
|
||||
AlertmanagertypesChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -9883,53 +9844,6 @@ components:
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableTraceSummary:
|
||||
properties:
|
||||
ai:
|
||||
$ref: '#/components/schemas/SpantypesTraceAISummary'
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
hasMissingSpans:
|
||||
type: boolean
|
||||
rootServiceEntryPoint:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
rootSpanStatusCode:
|
||||
type: string
|
||||
startTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalErrorSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
totalSpansCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- startTimestampMillis
|
||||
- endTimestampMillis
|
||||
- rootServiceName
|
||||
- rootServiceEntryPoint
|
||||
- rootSpanStatusCode
|
||||
- totalSpansCount
|
||||
- totalErrorSpansCount
|
||||
- hasMissingSpans
|
||||
type: object
|
||||
SpantypesGettableTraceThread:
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
prevCursor:
|
||||
type: string
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesThreadSpan'
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10244,102 +10158,6 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesThreadSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
formatted_input:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesMessage'
|
||||
type: array
|
||||
formatted_output:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesMessage'
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
span_id:
|
||||
type: string
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
required:
|
||||
- span_id
|
||||
- trace_id
|
||||
- parent_span_id
|
||||
- name
|
||||
- kind_string
|
||||
- time_unix
|
||||
- duration_nano
|
||||
- has_error
|
||||
- status_code_string
|
||||
- status_message
|
||||
- resource
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
$ref: '#/components/schemas/SpantypesTraceAITokens'
|
||||
totalCost:
|
||||
type: number
|
||||
required:
|
||||
- tokens
|
||||
type: object
|
||||
SpantypesTraceAITokens:
|
||||
properties:
|
||||
cacheRead:
|
||||
minimum: 0
|
||||
type: integer
|
||||
cacheWrite:
|
||||
minimum: 0
|
||||
type: integer
|
||||
input:
|
||||
minimum: 0
|
||||
type: integer
|
||||
output:
|
||||
minimum: 0
|
||||
type: integer
|
||||
reasoning:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- input
|
||||
- output
|
||||
- cacheRead
|
||||
- cacheWrite
|
||||
- reasoning
|
||||
type: object
|
||||
SpantypesUpdatableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -16064,164 +15882,6 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/summary:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the trace's time range, root span, span and error counts,
|
||||
and whether any spans are missing. AI traces also include token and cost totals.
|
||||
operationId: GetTraceSummary
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceSummary'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get summary for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order, each with the messages normalised into formatted_input and formatted_output.
|
||||
Pass nextCursor as after or prevCursor as before to page, or spanId to open
|
||||
the page around a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
in: query
|
||||
name: limit
|
||||
schema:
|
||||
description: Page size, at most 100. 0 means 20.
|
||||
type: integer
|
||||
- description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: after
|
||||
schema:
|
||||
description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
type: string
|
||||
- description: The prevCursor of a page; returns the spans before it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: before
|
||||
schema:
|
||||
description: The prevCursor of a page; returns the spans before it. Set
|
||||
only one of after, before and spanId.
|
||||
type: string
|
||||
- description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
in: query
|
||||
name: spanId
|
||||
schema:
|
||||
description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
type: string
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceThread'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get thread view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -4,53 +4,6 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
export enum AiobservabilitytypesPartTypeDTO {
|
||||
text = 'text',
|
||||
thinking = 'thinking',
|
||||
tool_call = 'tool_call',
|
||||
tool_result = 'tool_result',
|
||||
generic = 'generic',
|
||||
}
|
||||
export interface AiobservabilitytypesPartDTO {
|
||||
arguments?: unknown;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
content?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
isError?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
toolCallId?: string;
|
||||
type: AiobservabilitytypesPartTypeDTO;
|
||||
}
|
||||
|
||||
export interface AiobservabilitytypesMessageDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
content: AiobservabilitytypesPartDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
finishReason?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -11382,82 +11335,6 @@ export interface SpantypesGettableTraceAggregationsDTO {
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAITokensDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheRead: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
cacheWrite: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
input: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
output: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
reasoning: number;
|
||||
}
|
||||
|
||||
export interface SpantypesTraceAISummaryDTO {
|
||||
tokens: SpantypesTraceAITokensDTO;
|
||||
/**
|
||||
* @type number
|
||||
*/
|
||||
totalCost?: number;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceSummaryDTO {
|
||||
ai?: SpantypesTraceAISummaryDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
endTimestampMillis: number;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hasMissingSpans: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceEntryPoint: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
rootSpanStatusCode: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
startTimestampMillis: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalErrorSpansCount: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -11473,94 +11350,6 @@ export interface SpantypesOtelSpanRefDTO {
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
|
||||
|
||||
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
|
||||
|
||||
export interface SpantypesThreadSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributes: SpantypesThreadSpanDTOAttributes;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatted_input?: AiobservabilitytypesMessageDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatted_output?: AiobservabilitytypesMessageDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
resource: SpantypesThreadSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceThreadDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nextCursor?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
prevCursor?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesThreadSpanDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -13234,51 +13023,6 @@ export type GetTraceAggregations200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceSummaryPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceSummary200 = {
|
||||
data: SpantypesGettableTraceSummaryDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceThreadPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceThreadParams = {
|
||||
/**
|
||||
* @type integer
|
||||
* @description Page size, at most 100. 0 means 20.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
|
||||
*/
|
||||
after?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
|
||||
*/
|
||||
before?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Returns the page around this span. Set only one of after, before and spanId.
|
||||
*/
|
||||
spanId?: string;
|
||||
};
|
||||
|
||||
export type GetTraceThread200 = {
|
||||
data: SpantypesGettableTraceThreadDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -4,17 +4,11 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
@@ -22,11 +16,6 @@ import type {
|
||||
GetFlamegraphPathParameters,
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -38,26 +27,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
const withQueryKey = <T extends object, K>(
|
||||
query: T,
|
||||
queryKey: K,
|
||||
): T & { queryKey: K } => {
|
||||
const result = { queryKey } as T & { queryKey: K };
|
||||
for (const key of Object.keys(query)) {
|
||||
// The explicit queryKey always wins, matching the previous
|
||||
// `{ ...query, queryKey }` spread where it was set last.
|
||||
if (key === 'queryKey') {
|
||||
continue;
|
||||
}
|
||||
Object.defineProperty(result, key, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => (query as Record<string, unknown>)[key],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
@@ -158,223 +127,6 @@ export const useGetTraceAggregations = <
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const getTraceSummary = (
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceSummary200>({
|
||||
url: `/api/v1/traces/${traceID}/summary`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryKey = ({
|
||||
traceID,
|
||||
}: GetTraceSummaryPathParameters) => {
|
||||
return [`/api/v1/traces/${traceID}/summary`] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceSummaryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceSummaryQueryKey({ traceID });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceSummary>>> = ({
|
||||
signal,
|
||||
}) => getTraceSummary({ traceID }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceSummaryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>
|
||||
>;
|
||||
export type GetTraceSummaryQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceSummary<
|
||||
TData = Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceSummary>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceSummaryQueryOptions({ traceID }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get summary for a trace
|
||||
*/
|
||||
export const invalidateGetTraceSummary = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceSummaryPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceSummaryQueryKey({ traceID }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order, each with the messages normalised into formatted_input and formatted_output. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceThread200>({
|
||||
url: `/api/v1/traces/${traceID}/thread`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryKey = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/traces/${traceID}/thread`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
|
||||
signal,
|
||||
}) => getTraceThread({ traceID }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceThreadQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceThread>>
|
||||
>;
|
||||
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceThread<
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceThreadQueryOptions(
|
||||
{ traceID },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const invalidateGetTraceThread = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -30,6 +29,7 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
@@ -729,65 +729,71 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<TruncatedTooltip
|
||||
key={option.value || `option-${index}`}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
title={String(option.label || '')}
|
||||
>
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<div className="option-content">
|
||||
<Typography.Text truncate={1} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</Typography.Text>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -25,6 +26,7 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
@@ -207,41 +209,46 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
@@ -702,50 +709,67 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
const optionsWithHighlight = useMemo(
|
||||
() =>
|
||||
options
|
||||
?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
)
|
||||
?.map((option) => ({
|
||||
...option,
|
||||
label: highlightMatchedText(String(option.label || ''), searchText),
|
||||
})),
|
||||
[options, searchText, highlightMatchedText],
|
||||
);
|
||||
const optionsWithHighlight = useMemo(() => {
|
||||
const matching =
|
||||
options?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
// antd shows a value with no matching option as a bare string, so keep the
|
||||
// selected value listed to render it through the tooltip label below.
|
||||
return (
|
||||
isEmpty(value)
|
||||
? matching
|
||||
: prioritizeOrAddOptionForSingleSelect(matching, value)
|
||||
).map((option) => ({
|
||||
...option,
|
||||
// Rendered as the selected value in the closed control.
|
||||
label: (
|
||||
<TruncatedTooltip title={String(option.label || '')} side="bottom">
|
||||
{(textRef): JSX.Element => (
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
),
|
||||
}));
|
||||
}, [options, searchText, value, highlightMatchedText]);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
// Self-provided so label tooltips work wherever this select is rendered.
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Above the antd select popup it is shown from.
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactElement, RefObject, useRef, useState } from 'react';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './TruncatedTooltip.module.scss';
|
||||
|
||||
interface TruncatedTooltipProps {
|
||||
title: string;
|
||||
side?: TooltipSimpleProps['side'];
|
||||
/** Renders the trigger; attach the ref to the element that may truncate. */
|
||||
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
|
||||
}
|
||||
|
||||
function TruncatedTooltip({
|
||||
title,
|
||||
side = 'right',
|
||||
children,
|
||||
}: TruncatedTooltipProps): JSX.Element {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
const el = textRef.current;
|
||||
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={title}
|
||||
side={side}
|
||||
arrow
|
||||
delayDuration={300}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
tooltipContentProps={{ className: styles.tooltipContent }}
|
||||
>
|
||||
{children(textRef)}
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default TruncatedTooltip;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CustomSelect from '../CustomSelect';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
describe('CustomSelect selected value tooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['listed in the options', [{ label: LABEL, value: LABEL }]],
|
||||
['missing from the options', [{ label: 'other', value: 'other' }]],
|
||||
])('reveals a truncated selected value %s on hover', async (_, options) => {
|
||||
render(<CustomSelect options={options} value={LABEL} />);
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(screen.getByText(LABEL));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import TruncatedTooltip from '../TruncatedTooltip';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
function renderRow(scrollWidth: number, clientWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
||||
.mockReturnValue(scrollWidth);
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(clientWidth);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<TruncatedTooltip title={LABEL}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div data-testid="row">
|
||||
<span ref={textRef}>{LABEL}</span>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function hover(element: HTMLElement): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(element);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe('TruncatedTooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reveals truncated text when its row is hovered', async () => {
|
||||
renderRow(400, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
|
||||
it('shows no tooltip when the text fits', async () => {
|
||||
renderRow(200, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,51 @@ const LONG_LABEL_OPTION = {
|
||||
value: 'checkout-payment-authorisation',
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
|
||||
export const TruncatedOption: Story = {
|
||||
args: { options: [LONG_LABEL_OPTION, ...options] },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off selected value reveals it in full. */
|
||||
export const TruncatedValue: Story = {
|
||||
args: {
|
||||
options: [LONG_LABEL_OPTION, ...options],
|
||||
value: LONG_LABEL_OPTION.value,
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: a multi-select option reveals its full label the same way. */
|
||||
export const MultiTruncatedOption: Story = {
|
||||
render: (): JSX.Element => (
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
options={[LONG_LABEL_OPTION, ...options]}
|
||||
/>
|
||||
),
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Services' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the select renders, held open: the selected chip revealing the
|
||||
* option label it was cut from. Nothing bounds that label, so the chip is given
|
||||
|
||||
@@ -29,6 +29,13 @@ $custom-border-color: #2c3044;
|
||||
background-color: var(--l2-background);
|
||||
color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
// The search input overlays the selected value; lift the value above it while
|
||||
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
|
||||
&:not(.ant-select-open) .ant-select-selection-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chip styles ONLY in the multi-select
|
||||
@@ -223,6 +230,33 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
// Ellipsized option label; also the selected value in the closed single select.
|
||||
.option-label-text {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
|
||||
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
|
||||
@@ -10,23 +10,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/summary", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetTraceSummary),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceSummary",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get summary for a trace",
|
||||
Description: "Returns the trace's time range, root span, span and error counts, and whether any spans are missing. AI traces also include token and cost totals.",
|
||||
Response: new(spantypes.GettableTraceSummary),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
@@ -84,23 +67,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/traces/{traceID}/thread", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetThread),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetTraceThread",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get thread view for a trace",
|
||||
Description: "Returns the spans carrying gen_ai input or output messages in timestamp order, each with the messages normalised into formatted_input and formatted_output. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
|
||||
RequestQuery: new(spantypes.GetTraceThreadParams),
|
||||
Response: new(spantypes.GettableTraceThread),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -20,22 +18,6 @@ func NewHandler(module tracedetail.Module) tracedetail.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.module.GetTraceSummary(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, spantypes.NewGettableTraceSummary(stats))
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
@@ -93,31 +75,3 @@ func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetThread(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(spantypes.GetTraceThreadParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
query, err := spantypes.NewThreadQuery(req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetThread(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"], query)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
|
||||
@@ -40,41 +39,26 @@ func NewModule(traceStore spantypes.TraceStore, providerSettings factory.Provide
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats, err := m.store.GetTraceStats(ctx, orgID, traceID, bounds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stats.TotalSpans == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetWaterfallV4 is the OOM-safe V4 waterfall.
|
||||
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
|
||||
// minimal fields for all spans to build the tree, then full fields for the
|
||||
// visible window only. Aggregations are not returned.
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bounds.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
|
||||
if summary.NumSpans > uint64(m.config.Waterfall.MaxLimitToSelectAllSpans) {
|
||||
attrs := metric.WithAttributes(attrResponseType.String(attrResponseTypeWindowed))
|
||||
m.metrics.waterfallRequestCount.Add(ctx, 1, attrs)
|
||||
m.metrics.waterfallSpanCount.Add(ctx, int64(bounds.NumSpans), attrs)
|
||||
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
|
||||
m.metrics.waterfallSpanCount.Add(ctx, int64(summary.NumSpans), attrs)
|
||||
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
|
||||
}
|
||||
return m.getFullWaterfall(ctx, traceID, bounds)
|
||||
return m.getFullWaterfall(ctx, traceID, summary)
|
||||
}
|
||||
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, bounds)
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -94,24 +78,24 @@ func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *s
|
||||
}
|
||||
|
||||
func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
traceDurationNs := uint64(bounds.End.UnixNano()) - uint64(bounds.Start.UnixNano())
|
||||
traceDurationNs := uint64(summary.End.UnixNano()) - uint64(summary.Start.UnixNano())
|
||||
|
||||
results := make([]spantypes.SpanAggregationResult, 0, len(req.Aggregations))
|
||||
for _, agg := range req.Aggregations {
|
||||
result := spantypes.SpanAggregationResult{Field: agg.Field, Aggregation: agg.Aggregation}
|
||||
switch agg.Aggregation {
|
||||
case spantypes.SpanAggregationSpanCount:
|
||||
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, bounds, agg.Field)
|
||||
result.Value, err = m.store.GetSpanCountByField(ctx, traceID, summary, agg.Field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case spantypes.SpanAggregationDuration:
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
@@ -120,7 +104,7 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
|
||||
result.Value[k] = ns / 1_000_000
|
||||
}
|
||||
case spantypes.SpanAggregationExecutionTimePercentage:
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, bounds, agg.Field)
|
||||
durationNs, err2 := m.store.GetSpanDurationByField(ctx, traceID, summary, agg.Field)
|
||||
if err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
@@ -137,15 +121,15 @@ func (m *module) GetTraceAggregations(ctx context.Context, traceID string, req *
|
||||
}
|
||||
|
||||
func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bounds.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
|
||||
return m.getFullFlamegraph(ctx, traceID, bounds, selectFields)
|
||||
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
|
||||
return m.getFullFlamegraph(ctx, traceID, summary, selectFields)
|
||||
}
|
||||
m.metrics.flamegraphRequestCount.Add(ctx, 1, metric.WithAttributes(attrResponseType.String(attrResponseTypeSampled)))
|
||||
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, bounds, selectFields)
|
||||
return m.getWindowedFlamegraph(ctx, traceID, selectedSpanID, summary, selectFields)
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
@@ -189,51 +173,8 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
|
||||
), nil
|
||||
}
|
||||
|
||||
func (m *module) GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error) {
|
||||
bounds, err := m.store.GetTraceBounds(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// One extra row per side signals a next/prev page; NewGettableTraceThread trims the response back to query.Limit.
|
||||
page := spantypes.ThreadPage{Limit: query.Limit + 1}
|
||||
switch {
|
||||
case query.SpanID != "":
|
||||
// Window centred on the span: fetch both directions, NewGettableTraceThread splits the limit.
|
||||
anchor, err := m.store.GetThreadCursor(ctx, traceID, bounds, query.SpanID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadBefore, Limit: page.Limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ThreadAt keeps the anchor when it carries messages; otherwise the filter drops it.
|
||||
after, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, spantypes.ThreadPage{Cursor: anchor, From: spantypes.ThreadAt, Limit: page.Limit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, before, after), nil
|
||||
case query.Before != nil:
|
||||
page.Cursor, page.From = query.Before, spantypes.ThreadBefore
|
||||
before, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, before, nil), nil
|
||||
default:
|
||||
// nil for the first page.
|
||||
page.Cursor = query.After
|
||||
after, err := m.store.GetThreadSpans(ctx, orgID, traceID, bounds, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spantypes.NewGettableTraceThread(traceID, query, nil, after), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, nil)
|
||||
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -246,8 +187,8 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, bounds *
|
||||
}
|
||||
|
||||
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
|
||||
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, bounds *spantypes.TraceBounds, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, bounds.Start, bounds.End)
|
||||
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error) {
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -266,7 +207,7 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, bounds.Start, bounds.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
|
||||
fullSpans, err := m.store.GetFlamegraphSpans(ctx, traceID, summary.Start, summary.End, spantypes.FlamegraphWindowSpanIDs(selectedSpans))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,22 +4,15 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
sqlbuilder "github.com/huandu/go-sqlbuilder"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
"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/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
|
||||
@@ -45,21 +38,13 @@ type spanDurationRow struct {
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
metadataStore telemetrytypes.MetadataStore
|
||||
storage qbtypes.Storage
|
||||
flagger flagger.Flagger
|
||||
}
|
||||
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore, metadataStore telemetrytypes.MetadataStore, fl flagger.Flagger) *traceStore {
|
||||
return &traceStore{
|
||||
telemetryStore: ts,
|
||||
metadataStore: metadataStore,
|
||||
storage: tracestelemetryschema.NewStorage(),
|
||||
flagger: fl,
|
||||
}
|
||||
func NewTraceStore(ts telemetrystore.TelemetryStore) *traceStore {
|
||||
return &traceStore{telemetryStore: ts}
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceBounds(ctx context.Context, traceID string) (*spantypes.TraceBounds, error) {
|
||||
func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*spantypes.TraceSummary, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("trace_id", "min(start) AS start", "max(end) AS end", "sum(num_spans) AS num_spans")
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceSummaryTable))
|
||||
@@ -67,135 +52,20 @@ func (s *traceStore) GetTraceBounds(ctx context.Context, traceID string) (*spant
|
||||
sb.GroupBy("trace_id")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var bounds spantypes.TraceBounds
|
||||
var summary spantypes.TraceSummary
|
||||
err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&bounds.TraceID, &bounds.Start, &bounds.End, &bounds.NumSpans,
|
||||
&summary.TraceID, &summary.Start, &summary.End, &summary.NumSpans,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace bounds")
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace summary")
|
||||
}
|
||||
return &bounds, nil
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) (*spantypes.TraceStats, error) {
|
||||
table := fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable)
|
||||
spans := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
genAIColumns, err := s.genAISpanColumns(ctx, orgID, bounds, spans)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Spans whose parent is absent from the trace; these also count as roots and surface as "Missing Span".
|
||||
ids := sqlbuilder.NewSelectBuilder()
|
||||
ids.Select("span_id")
|
||||
ids.From(table)
|
||||
ids.Where(
|
||||
ids.E("trace_id", traceID),
|
||||
ids.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
ids.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
)
|
||||
missingParent := fmt.Sprintf("parent_span_id <> '' AND parent_span_id GLOBAL NOT IN (%s)", spans.Var(ids))
|
||||
|
||||
spans.Select(
|
||||
"toUnixTimestamp64Nano(timestamp) AS span_start_ns",
|
||||
"span_start_ns + duration_nano AS span_end_ns",
|
||||
"span_id",
|
||||
"has_error",
|
||||
"("+missingParent+") AS has_missing_parent",
|
||||
"(parent_span_id = '' OR has_missing_parent) AS is_root",
|
||||
"if(parent_span_id = '', name, 'Missing Span') AS root_name",
|
||||
"if(parent_span_id = '', "+colServiceName+", '') AS root_service",
|
||||
"if(parent_span_id = '', response_status_code, '') AS root_status_code",
|
||||
)
|
||||
|
||||
spans.SelectMore(genAIColumns...)
|
||||
spans.From(table)
|
||||
spans.Where(
|
||||
spans.E("trace_id", traceID),
|
||||
spans.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
spans.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
)
|
||||
// A span can be ingested more than once; keep one row per span_id.
|
||||
spans.SQL("LIMIT 1 BY span_id")
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"toUInt64(min(span_start_ns)) AS start_ns",
|
||||
"toUInt64(max(span_end_ns)) AS end_ns",
|
||||
"count() AS total_spans",
|
||||
"countIf(has_error) AS total_error_spans",
|
||||
"countIf(has_missing_parent) > 0 AS has_missing_spans",
|
||||
"argMinIf(root_service, (span_start_ns, root_name), is_root) AS root_service_name",
|
||||
"argMinIf(root_name, (span_start_ns, root_name), is_root) AS root_entry_point",
|
||||
"argMinIf(root_status_code, (span_start_ns, root_name), is_root) AS root_span_status_code",
|
||||
"countIf(is_gen_ai) AS gen_ai_span_count",
|
||||
"toUInt64(coalesce(sum(input_tokens_value), 0)) AS input_tokens",
|
||||
"toUInt64(coalesce(sum(output_tokens_value), 0)) AS output_tokens",
|
||||
"toUInt64(coalesce(sum(cache_read_tokens_value), 0)) AS cache_read_tokens",
|
||||
"toUInt64(coalesce(sum(cache_write_tokens_value), 0)) AS cache_write_tokens",
|
||||
"toUInt64(coalesce(sum(reasoning_tokens_value), 0)) AS reasoning_tokens",
|
||||
"sum(total_cost_value) AS total_cost",
|
||||
)
|
||||
sb.From(sb.BuilderAs(spans, "spans"))
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var stats spantypes.TraceStats
|
||||
err = s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(
|
||||
&stats.StartNs, &stats.EndNs, &stats.TotalSpans, &stats.TotalErrorSpans, &stats.HasMissingSpans,
|
||||
&stats.RootServiceName, &stats.RootEntryPoint, &stats.RootSpanStatusCode, &stats.GenAISpanCount,
|
||||
&stats.Tokens.Input, &stats.Tokens.Output, &stats.Tokens.CacheRead, &stats.Tokens.CacheWrite, &stats.Tokens.Reasoning,
|
||||
&stats.TotalCost,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace stats")
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// genAISpanColumns returns the gen_ai columns aggregated per span, resolved across attribute evolutions.
|
||||
func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) ([]string, error) {
|
||||
values := []struct{ key, alias string }{
|
||||
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageCacheReadInputTokens, "cache_read_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, "cache_write_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageReasoningOutputTokens, "reasoning_tokens_value"},
|
||||
{aiobservabilitytypes.SignozGenAITotalCost, "total_cost_value"},
|
||||
}
|
||||
|
||||
names := slices.Clone(aiobservabilitytypes.GenAISpanGateKeys)
|
||||
for _, value := range values {
|
||||
names = append(names, value.key)
|
||||
}
|
||||
keys, err := s.genAIFieldKeys(ctx, orgID, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
|
||||
isGenAI, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := []string{isGenAI + " AS is_gen_ai"}
|
||||
|
||||
for _, value := range values {
|
||||
// lookup by number, the type metadata stores numeric attributes under; float64 is only the output cast
|
||||
expr, err := querybuilder.ResolveColumn(ctx, q, s.storage, attributeKey(value.key, telemetrytypes.FieldDataTypeNumber), telemetrytypes.FieldDataTypeFloat64, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// a materialized column name carries `$$`, which Build would otherwise unescape
|
||||
columns = append(columns, sqlbuilder.Escape(expr)+" AS "+value.alias)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
|
||||
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
|
||||
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
|
||||
query := fmt.Sprintf(`
|
||||
SELECT DISTINCT ON (span_id)
|
||||
@@ -215,8 +85,8 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *
|
||||
err := s.telemetryStore.ClickhouseDB().Select(
|
||||
ctx, &spanItems, query,
|
||||
traceID,
|
||||
bounds.Start.Unix()-1800,
|
||||
bounds.End.Unix(),
|
||||
summary.Start.Unix()-1800,
|
||||
summary.End.Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans")
|
||||
@@ -254,7 +124,8 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "has_error", "kind",
|
||||
"DISTINCT ON (span_id) timestamp",
|
||||
"duration_nano", "span_id", "has_error", "kind",
|
||||
colServiceName, "name",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
@@ -284,92 +155,6 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds, page spantypes.ThreadPage) ([]spantypes.StorableSpan, error) {
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",
|
||||
"status_code_string", "status_message", "resources_string",
|
||||
"attributes_string", "attributes_number", "attributes_bool",
|
||||
"events", "links as references",
|
||||
)
|
||||
if q.TraceAttrsJSONOn {
|
||||
sb.SelectMore("attributes")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
messageKeys := []string{aiobservabilitytypes.GenAIInputMessages, aiobservabilitytypes.GenAIOutputMessages}
|
||||
keys, err := s.genAIFieldKeys(ctx, orgID, messageKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasMessages, err := s.anyExistsCondition(ctx, q, messageKeys, keys, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
hasMessages,
|
||||
)
|
||||
if cursor := page.Cursor; cursor != nil {
|
||||
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
|
||||
// ts_bucket_start bounds are what skip the data on the far side of the cursor.
|
||||
key := "(toUnixTimestamp64Nano(timestamp), span_id)"
|
||||
bucket := int64(cursor.TimeUnixNano / uint64(time.Second))
|
||||
timestamp := fmt.Sprintf("fromUnixTimestamp64Nano(toInt64(%s))", sb.Var(cursor.TimeUnixNano))
|
||||
tuple := sqlbuilder.Tuple(cursor.TimeUnixNano, cursor.SpanID)
|
||||
switch page.From {
|
||||
case spantypes.ThreadBefore:
|
||||
sb.Where(sb.LE("ts_bucket_start", bucket), "timestamp <= "+timestamp, sb.LT(key, tuple))
|
||||
case spantypes.ThreadAt:
|
||||
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GE(key, tuple))
|
||||
default:
|
||||
sb.Where(sb.GE("ts_bucket_start", bucket-1800), "timestamp >= "+timestamp, sb.GT(key, tuple))
|
||||
}
|
||||
}
|
||||
// span_id breaks timestamp ties so the order matches the cursor key; otherwise tied spans
|
||||
// can be skipped or repeated across pages.
|
||||
if page.From == spantypes.ThreadBefore {
|
||||
sb.OrderByDesc("timestamp")
|
||||
sb.OrderByDesc("span_id")
|
||||
} else {
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("span_id")
|
||||
}
|
||||
sb.Limit(page.Limit)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadCursor(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, spanID string) (*spantypes.ThreadCursor, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select("toUnixTimestamp64Nano(timestamp)")
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
sb.E("span_id", spanID),
|
||||
)
|
||||
sb.Limit(1)
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var timeUnixNano int64
|
||||
if err := s.telemetryStore.ClickhouseDB().QueryRow(ctx, query, args...).Scan(&timeUnixNano); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errors.NewNotFoundf(spantypes.ErrCodeThreadSpanNotFound, "span %s not found in trace %s", spanID, traceID)
|
||||
}
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying thread span")
|
||||
}
|
||||
return &spantypes.ThreadCursor{TimeUnixNano: uint64(timeUnixNano), SpanID: spanID}, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
@@ -411,7 +196,7 @@ func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, sta
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
fieldExpr, err := buildFieldExpr(fieldKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -421,8 +206,8 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bo
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
"notEmpty("+fieldExpr+")",
|
||||
)
|
||||
sb.GroupBy("field_value")
|
||||
@@ -439,7 +224,7 @@ func (s *traceStore) GetSpanCountByField(ctx context.Context, traceID string, bo
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, bounds *spantypes.TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string, summary *spantypes.TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error) {
|
||||
fieldExpr, err := buildFieldExpr(fieldKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -455,8 +240,8 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
|
||||
allSpansSB.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
allSpansSB.Where(
|
||||
allSpansSB.E("trace_id", traceID),
|
||||
allSpansSB.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
allSpansSB.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
allSpansSB.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
allSpansSB.LE("ts_bucket_start", summary.End.Unix()),
|
||||
"notEmpty(field_value)",
|
||||
)
|
||||
allSpansSB.OrderByAsc("timestamp")
|
||||
@@ -492,34 +277,3 @@ func (s *traceStore) GetSpanDurationByField(ctx context.Context, traceID string,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func attributeKey(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
|
||||
}
|
||||
|
||||
// genAIFieldKeys fetches the metadata keys for the attribute names, expanded across semconv families.
|
||||
func (s *traceStore) genAIFieldKeys(ctx context.Context, orgID valuer.UUID, names []string) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(names))
|
||||
for _, name := range names {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact})
|
||||
}
|
||||
keys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, s.flagger, selectors))
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error fetching gen_ai field keys")
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// anyExistsCondition ORs an EXISTS test per attribute name, resolved across attribute evolutions
|
||||
// and the use_trace_attributes_json flag so the filter reads the same columns the query builder does.
|
||||
func (s *traceStore) anyExistsCondition(ctx context.Context, q qbtypes.QueryInfo, names []string, keys map[string][]*telemetrytypes.TelemetryFieldKey, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
conds := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conds = append(conds, keyConds...)
|
||||
}
|
||||
return sb.Or(conds...), nil
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,23 +6,18 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
type Handler interface {
|
||||
GetTraceSummary(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
GetTraceAggregations(http.ResponseWriter, *http.Request)
|
||||
GetFlamegraph(http.ResponseWriter, *http.Request)
|
||||
GetThread(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
|
||||
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
|
||||
GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)
|
||||
}
|
||||
|
||||
@@ -566,6 +566,24 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
|
||||
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
|
||||
for k, v := range m {
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch child := v.(type) {
|
||||
case map[string]any:
|
||||
flattenJSONPaths(key, child, out)
|
||||
case telemetrystoretypes.JSONValue:
|
||||
flattenJSONPaths(key, child, out)
|
||||
default:
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
|
||||
// unified "attributes" and "resource" keys, and parses the stringified `events`
|
||||
// and `links` columns into structured slices. Raw DB columns are removed.
|
||||
@@ -580,7 +598,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
|
||||
attributes := make(map[string]any)
|
||||
attrJSON.FlattenInto("", attributes)
|
||||
flattenJSONPaths("", attrJSON, attributes)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
|
||||
@@ -161,7 +161,7 @@ func NewModules(
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, fl), providerSettings, config.TraceDetail),
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: spanMapper,
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
|
||||
Tag: tagModule,
|
||||
|
||||
@@ -15,12 +15,11 @@ var (
|
||||
aiobservabilitytypes.GenAIAgentName: genAIAttribute(aiobservabilitytypes.GenAIAgentName, telemetrytypes.FieldDataTypeString),
|
||||
aiobservabilitytypes.GenAIProviderName: genAIAttribute(aiobservabilitytypes.GenAIProviderName, telemetrytypes.FieldDataTypeString),
|
||||
|
||||
aiobservabilitytypes.GenAIUsageInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageOutputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageCacheReadInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheReadInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageCacheCreationInputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageCacheCreationInputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.GenAIUsageReasoningOutputTokens: genAIAttribute(aiobservabilitytypes.GenAIUsageReasoningOutputTokens, telemetrytypes.FieldDataTypeNumber),
|
||||
aiobservabilitytypes.SignozGenAITotalCost: genAIAttribute(aiobservabilitytypes.SignozGenAITotalCost, telemetrytypes.FieldDataTypeNumber),
|
||||
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),
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testdata/captured/<sdk>/<attribute>.txt holds a real attribute value from the scripts
|
||||
// repo's static-telemetry-generator captures; <attribute>.want.json is its reviewed output.
|
||||
func TestNormalizeCapturedPayloads(t *testing.T) {
|
||||
inputs, err := filepath.Glob(filepath.Join("testdata", "captured", "*", "*.txt"))
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, inputs)
|
||||
|
||||
for _, input := range inputs {
|
||||
sdk := filepath.Base(filepath.Dir(input))
|
||||
attribute := strings.TrimSuffix(filepath.Base(input), ".txt")
|
||||
t.Run(sdk+"/"+attribute, func(t *testing.T) {
|
||||
raw, err := os.ReadFile(input)
|
||||
require.NoError(t, err)
|
||||
wantJSON, err := os.ReadFile(strings.TrimSuffix(input, ".txt") + ".want.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
var want []aiobservabilitytypes.Message
|
||||
require.NoError(t, json.Unmarshal(wantJSON, &want))
|
||||
got, err := json.Marshal(Normalize(string(raw)))
|
||||
require.NoError(t, err)
|
||||
var gotRoundTrip []aiobservabilitytypes.Message
|
||||
require.NoError(t, json.Unmarshal(got, &gotRoundTrip))
|
||||
|
||||
assert.Equal(t, want, gotRoundTrip)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
// convertSemconvMessages handles [{role, parts, finish_reason}] and Gemini contents.
|
||||
func convertSemconvMessages(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
list, ok := value.([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
first, ok := toObject(firstItem(list))
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if ok := first.has("parts"); !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
messages := make([]aiobservabilitytypes.Message, 0, len(list))
|
||||
for _, item := range list {
|
||||
m, ok := toObject(item)
|
||||
if !ok {
|
||||
messages = append(messages, genericMessages(stringOf(item))...)
|
||||
continue
|
||||
}
|
||||
messages = append(messages, semconvMessage(m, "")...)
|
||||
}
|
||||
return messages, true
|
||||
}
|
||||
|
||||
// convertChatMessageList handles OpenAI, Anthropic, Vercel and LangChain message lists.
|
||||
func convertChatMessageList(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
list, ok := value.([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if !isChatMessage(firstItem(list)) {
|
||||
return nil, false
|
||||
}
|
||||
messages := make([]aiobservabilitytypes.Message, 0, len(list))
|
||||
for _, item := range list {
|
||||
messages = append(messages, chatMessage(item, "")...)
|
||||
}
|
||||
return messages, true
|
||||
}
|
||||
|
||||
func isChatMessage(value any) bool {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if ok := m.has("role"); ok {
|
||||
return true
|
||||
}
|
||||
switch typ := m.str("type"); typ {
|
||||
case "message", "reasoning", "human", "ai", "tool", "system":
|
||||
return true
|
||||
case "constructor":
|
||||
ok := m.has("kwargs")
|
||||
return ok
|
||||
default:
|
||||
return isResponsesItemType(typ)
|
||||
}
|
||||
}
|
||||
|
||||
// convertToolCallList handles a bare tool call list, e.g. Vercel ai.response.toolCalls.
|
||||
func convertToolCallList(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
list, ok := value.([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: make([]aiobservabilitytypes.Part, 0, len(list))}
|
||||
for _, item := range list {
|
||||
call, ok := toObject(item)
|
||||
if !ok || !isToolCall(call) {
|
||||
return nil, false
|
||||
}
|
||||
msg.Content = append(msg.Content, toolCallPart(call))
|
||||
}
|
||||
return []aiobservabilitytypes.Message{msg}, true
|
||||
}
|
||||
|
||||
// isToolCall rejects tool definitions, which carry no arguments.
|
||||
func isToolCall(call object) bool {
|
||||
if has := call.has("toolName"); has {
|
||||
return true
|
||||
}
|
||||
if fn, ok := call.obj("function"); ok {
|
||||
has := fn.has("arguments")
|
||||
return has
|
||||
}
|
||||
if has := call.has("name"); !has {
|
||||
return false
|
||||
}
|
||||
_, has := call.lookup("arguments", "args")
|
||||
return has
|
||||
}
|
||||
|
||||
// convertChatRequest handles OpenAI, Anthropic, Vercel, Gemini and LangChain request objects.
|
||||
func convertChatRequest(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
conversation, ok := m.lookup("messages", "input", "contents", "prompt")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
messages := []aiobservabilitytypes.Message{}
|
||||
if system := systemMessage(m.first("system", "instructions", "system_instruction", "systemInstruction", "system_prompt")); system != nil {
|
||||
messages = append(messages, *system)
|
||||
} else if config, ok := m.obj("config"); ok {
|
||||
if system := systemMessage(config.first("system_instruction", "systemInstruction")); system != nil {
|
||||
messages = append(messages, *system)
|
||||
}
|
||||
}
|
||||
|
||||
// {messages: "[...]"}: the list arrives JSON-encoded once more from some SDKs
|
||||
if s, isString := conversation.(string); isString {
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err == nil {
|
||||
if _, isList := decoded.([]any); isList {
|
||||
conversation = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
switch c := conversation.(type) {
|
||||
case string:
|
||||
if c != "" {
|
||||
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, c))
|
||||
}
|
||||
case []any:
|
||||
for _, item := range flattenOnce(c) {
|
||||
if s, isString := item.(string); isString {
|
||||
messages = append(messages, textMessage(aiobservabilitytypes.MessageRoleUser, s))
|
||||
continue
|
||||
}
|
||||
messages = append(messages, semconvMessage(asObject(item), aiobservabilitytypes.MessageRoleUser)...)
|
||||
}
|
||||
case map[string]any:
|
||||
messages = append(messages, semconvMessage(c, aiobservabilitytypes.MessageRoleUser)...)
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
return messages, true
|
||||
}
|
||||
|
||||
// systemMessage returns nil when value is empty or unknown.
|
||||
func systemMessage(value any) *aiobservabilitytypes.Message {
|
||||
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleSystem, Content: []aiobservabilitytypes.Part{}}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
msg.Content = append(msg.Content, textPart(v))
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
msg.Content = append(msg.Content, contentBlockPart(item))
|
||||
}
|
||||
case map[string]any:
|
||||
parts, ok := object(v).list("parts")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, p := range parts {
|
||||
msg.Content = append(msg.Content, semconvPart(p))
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if len(msg.Content) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &msg
|
||||
}
|
||||
|
||||
// convertChatResponse handles the OpenAI Chat Completions {choices} response.
|
||||
func convertChatResponse(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
choices, ok := m.list("choices")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
messages := make([]aiobservabilitytypes.Message, 0, len(choices))
|
||||
for _, c := range choices {
|
||||
choice := asObject(c)
|
||||
var converted []aiobservabilitytypes.Message
|
||||
if message, ok := toObject(choice.first("message", "delta")); ok {
|
||||
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
|
||||
} else if text, ok := choice.get("text"); ok {
|
||||
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, stringOf(text))}
|
||||
} else {
|
||||
converted = []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(choice)}}}
|
||||
}
|
||||
messages = append(messages, withFinishReason(converted, choice.str("finish_reason"))...)
|
||||
}
|
||||
return messages, true
|
||||
}
|
||||
|
||||
func convertResponsesAPIResponse(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
output, ok := m.list("output")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
messages := make([]aiobservabilitytypes.Message, 0, len(output))
|
||||
for _, item := range output {
|
||||
messages = append(messages, chatMessage(item, aiobservabilitytypes.MessageRoleAssistant)...)
|
||||
}
|
||||
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
|
||||
if details, ok := m.obj("incomplete_details"); ok {
|
||||
last.FinishReason = normalizeFinishReason(details.str("reason"))
|
||||
} else if m.str("status") == "completed" {
|
||||
last.FinishReason = aiobservabilitytypes.FinishReasonStop
|
||||
}
|
||||
}
|
||||
return messages, true
|
||||
}
|
||||
|
||||
// convertGeminiResponse handles Gemini {candidates} and Google ADK {content} responses.
|
||||
func convertGeminiResponse(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if content, ok := m.obj("content"); ok {
|
||||
if hasParts := content.has("parts"); hasParts {
|
||||
return withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(m)), true
|
||||
}
|
||||
}
|
||||
candidates, ok := m.list("candidates")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
messages := make([]aiobservabilitytypes.Message, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
candidate := asObject(c)
|
||||
content, ok := candidate.obj("content")
|
||||
if !ok {
|
||||
generic := []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{genericPart(candidate)}}}
|
||||
messages = append(messages, withFinishReason(generic, finishReasonOf(candidate))...)
|
||||
continue
|
||||
}
|
||||
messages = append(messages, withFinishReason(semconvMessage(content, aiobservabilitytypes.MessageRoleAssistant), finishReasonOf(candidate))...)
|
||||
}
|
||||
return messages, true
|
||||
}
|
||||
|
||||
func convertLegacyCompletion(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
completion, ok := m.text("completion")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{}}
|
||||
if reasoning, ok := m.text("reasoning"); ok && reasoning != "" {
|
||||
msg.Content = append(msg.Content, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: reasoning})
|
||||
}
|
||||
msg.Content = append(msg.Content, textPart(completion))
|
||||
return []aiobservabilitytypes.Message{msg}, true
|
||||
}
|
||||
|
||||
func convertLangChainGenerations(value any) ([]aiobservabilitytypes.Message, bool) {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
generations, ok := m.list("generations")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
messages := []aiobservabilitytypes.Message{}
|
||||
for _, g := range flattenOnce(generations) {
|
||||
gen := asObject(g)
|
||||
var converted []aiobservabilitytypes.Message
|
||||
if message, ok := gen.obj("message"); ok {
|
||||
converted = chatMessage(message, aiobservabilitytypes.MessageRoleAssistant)
|
||||
} else {
|
||||
converted = []aiobservabilitytypes.Message{textMessage(aiobservabilitytypes.MessageRoleAssistant, gen.str("text"))}
|
||||
}
|
||||
messages = append(messages, withFinishReason(converted, asObject(gen.at("generation_info")).str("finish_reason"))...)
|
||||
}
|
||||
return messages, true
|
||||
}
|
||||
|
||||
// withFinishReason sets reason on the last message that has none.
|
||||
func withFinishReason(messages []aiobservabilitytypes.Message, reason string) []aiobservabilitytypes.Message {
|
||||
if last := lastMessage(messages); last != nil && last.FinishReason == "" {
|
||||
last.FinishReason = normalizeFinishReason(reason)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
func TestNormalizeEnvelopeRequests(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "AnthropicRequest_SystemStringBecomesSystemMessage",
|
||||
raw: `{
|
||||
"model": "claude",
|
||||
"system": "Be brief.",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
"max_tokens": 100
|
||||
}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleSystem,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GeminiRequest_ConfigSystemInstructionBecomesSystemMessage",
|
||||
raw: `{
|
||||
"model": "gemini-2.0",
|
||||
"config": {"system_instruction": "Be brief."},
|
||||
"contents": [
|
||||
{"role": "user", "parts": [{"text": "Hi"}]},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"function_response": {"name": "get_weather", "response": {"temp": 20}}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleSystem,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, Name: "get_weather", Content: `{"temp":20}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GeminiRequest_SystemInstructionPartsBecomeSystemMessage",
|
||||
raw: `{"system_instruction": {"parts": [{"text": "Be brief."}]}, "contents": [{"role": "user", "parts": [{"text": "Hi"}]}]}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleSystem,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Be brief."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RequestWithMessagesAsJSONString_MessagesDecoded",
|
||||
raw: `{"messages":"[{\"role\":\"user\",\"content\":\"Hi\"}]"}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeEnvelopeChatResponses(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "StreamingChunkDelta_BecomesAssistantText",
|
||||
raw: `{"choices": [{"delta": {"role": "assistant", "content": "Hel"}, "finish_reason": null}]}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hel"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LegacyCompletionChoiceText_BecomesAssistantTextWithLength",
|
||||
raw: `{"choices": [{"text": "Hello", "finish_reason": "length"}]}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonLength,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FilteredChoiceWithoutMessage_BecomesGenericWithContentFilter",
|
||||
raw: `{"choices": [{"index": 0, "finish_reason": "content_filter"}]}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonContentFilter,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"finish_reason":"content_filter","index":0}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeEnvelopeOpenAIResponses(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "ResponsesAPIResponse_MessageAndReasoningItemsConverted",
|
||||
raw: `{
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_1",
|
||||
"summary": [{"type": "summary_text", "text": "Thinking about it"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Paris."}]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Thinking about it"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Paris."},
|
||||
},
|
||||
FinishReason: aiobservabilitytypes.FinishReasonStop,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ResponsesAPIResponseIncomplete_EncryptedReasoningEmptyAndFinishReasonLength",
|
||||
raw: `{
|
||||
"output": [
|
||||
{"type": "reasoning", "content": [{"type": "reasoning_text", "text": "step 1"}]},
|
||||
{"type": "reasoning", "encrypted_content": "gAAAA"}
|
||||
],
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {"reason": "max_output_tokens"}
|
||||
}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeThinking, Content: "step 1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonLength,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeThinking},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeEnvelopeGemini(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "GeminiResponse_ThoughtAndFunctionCallPartsConverted",
|
||||
raw: `{
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "Let me check", "thought": true},
|
||||
{"function_call": {"name": "get_weather", "args": {"city": "Paris"}}}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}
|
||||
],
|
||||
"usageMetadata": {}
|
||||
}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonStop,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me check"},
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GoogleADKResponse_ContentBecomesAssistantMessageWithStop",
|
||||
raw: `{"content": {"parts": [{"text": "It is 18°C and clear in Bengaluru."}], "role": "model"}, "finish_reason": "STOP"}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonStop,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "It is 18°C and clear in Bengaluru."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GeminiResponseBlockedBySafety_BecomesGenericWithContentFilter",
|
||||
raw: `{"candidates": [{"finishReason": "SAFETY", "index": 0}]}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonContentFilter,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"finishReason":"SAFETY","index":0}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeEnvelopeLangChain(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "LangChainGenerationWithOnlyText_BecomesAssistantTextWithLength",
|
||||
raw: `{"generations": [[{"text": "Observability is the ability", "generation_info": {"finish_reason": "length"}}]]}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonLength,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Observability is the ability"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
// Ordered by specificity: earlier converters never match a later format.
|
||||
var converters = []converter{
|
||||
convertSemconvMessages,
|
||||
convertChatMessageList,
|
||||
convertToolCallList,
|
||||
convertChatRequest,
|
||||
convertChatResponse,
|
||||
convertResponsesAPIResponse,
|
||||
convertGeminiResponse,
|
||||
convertLegacyCompletion,
|
||||
convertLangChainGenerations,
|
||||
}
|
||||
|
||||
// Normalize converts a gen_ai.*.messages value, a JSON string or a decoded value.
|
||||
// A string that is not JSON becomes one text part; JSON in no known format becomes
|
||||
// one generic part holding the original.
|
||||
func Normalize(raw any) []aiobservabilitytypes.Message {
|
||||
var (
|
||||
value any
|
||||
original string
|
||||
)
|
||||
switch v := raw.(type) {
|
||||
case nil:
|
||||
return []aiobservabilitytypes.Message{}
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return []aiobservabilitytypes.Message{}
|
||||
}
|
||||
original = v
|
||||
if err := json.Unmarshal([]byte(v), &value); err != nil {
|
||||
return []aiobservabilitytypes.Message{{Content: []aiobservabilitytypes.Part{textPart(v)}}}
|
||||
}
|
||||
default:
|
||||
value = v
|
||||
original = stringOf(v)
|
||||
}
|
||||
if value == nil {
|
||||
return []aiobservabilitytypes.Message{}
|
||||
}
|
||||
|
||||
if list, ok := value.([]any); ok {
|
||||
if len(list) == 0 {
|
||||
return []aiobservabilitytypes.Message{}
|
||||
}
|
||||
// [[...]]: some SDKs wrap the conversation in one more list
|
||||
if _, nested := firstItem(list).([]any); nested {
|
||||
value = flattenOnce(list)
|
||||
}
|
||||
// ["{...}", "{...}"]: an array attribute holding one JSON message per element
|
||||
if decoded, ok := decodeJSONStrings(list); ok {
|
||||
value = decoded
|
||||
}
|
||||
}
|
||||
for _, convert := range converters {
|
||||
if messages, ok := convert(value); ok {
|
||||
return messages
|
||||
}
|
||||
}
|
||||
return genericMessages(original)
|
||||
}
|
||||
|
||||
type converter func(value any) (messages []aiobservabilitytypes.Message, ok bool)
|
||||
@@ -1,146 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "ListNestedInAnotherList_Unwrapped",
|
||||
raw: `[[{"role":"user","content":"Hi"}]]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ListOfToolDefinitions_FallsBackToGeneric",
|
||||
raw: `[{"type":"function","function":{"name":"get_weather","description":"Weather","parameters":{"type":"object"}}}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `[{"type":"function","function":{"name":"get_weather","description":"Weather","parameters":{"type":"object"}}}]`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ListOfMessagesAsJSONStrings_EachDecoded",
|
||||
raw: []any{`{"role":"user","content":"Hi"}`, `{"role":"assistant","content":"Hello"}`},
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TextThatIsNotJSON_ReturnedAsOneTextPart",
|
||||
raw: "Let the cost of the ball be x dollars.",
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Let the cost of the ball be x dollars."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "JSONInNoKnownFormat_ReturnedVerbatimAsGeneric",
|
||||
raw: `{"output": "{\"query\": \"SigNoz\"}", "kwargs": {"name": "search_web"}}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"output": "{\"query\": \"SigNoz\"}", "kwargs": {"name": "search_web"}}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AlreadyDecodedValue_ConvertedLikeJSON",
|
||||
raw: []any{map[string]any{"role": "user", "content": "hi"}},
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "hi"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BlankString_ReturnsNoMessages",
|
||||
raw: " \n\t ",
|
||||
want: []aiobservabilitytypes.Message{},
|
||||
},
|
||||
{
|
||||
name: "JSONNull_ReturnsNoMessages",
|
||||
raw: `null`,
|
||||
want: []aiobservabilitytypes.Message{},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzNormalize(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
`[{"role": "user", "parts": [{"type": "text", "content": "Hi"}]}]`,
|
||||
`[{"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_1", "name": "f", "input": {}}]}]`,
|
||||
`[{"role": "tool", "tool_call_id": "call_1", "content": [{"type": "text", "text": "ok"}]}]`,
|
||||
`{"messages": [{"role": "user", "content": "Hi"}], "system": [{"type": "text", "text": "Be brief."}]}`,
|
||||
`{"choices": [{"message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}]}`,
|
||||
`{"output": [{"type": "function_call", "call_id": "c", "name": "f", "arguments": "{}"}, {"type": "reasoning"}]}`,
|
||||
`{"candidates": [{"content": {"parts": [{"functionCall": {"name": "f", "args": {}}}], "role": "model"}}]}`,
|
||||
`{"generations": [[{"text": "Hi", "message": {"lc": 1, "type": "constructor", "id": ["AIMessage"], "kwargs": {}}}]]}`,
|
||||
`[[{"role": "user", "content": "Hi"}]]`,
|
||||
`["{\"role\": \"user\", \"content\": \"Hi\"}"]`,
|
||||
``, `null`, `{}`, `[]`, `[{}]`, `"text"`, `42`,
|
||||
} {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
inputs := []any{raw}
|
||||
var decoded any
|
||||
if json.Unmarshal([]byte(raw), &decoded) == nil {
|
||||
inputs = append(inputs, decoded)
|
||||
}
|
||||
for _, input := range inputs {
|
||||
for i, message := range Normalize(input) {
|
||||
if message.Content == nil {
|
||||
t.Fatalf("message %d has nil content for %#v", i, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type normalizeCase struct {
|
||||
name string
|
||||
raw any
|
||||
want []aiobservabilitytypes.Message
|
||||
}
|
||||
|
||||
func assertNormalize(t *testing.T, testCases []normalizeCase) {
|
||||
t.Helper()
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, Normalize(testCase.raw))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
// chatMessage returns nil for LangGraph tool definitions.
|
||||
func chatMessage(value any, defaultRole aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Message {
|
||||
m, ok := toObject(value)
|
||||
if !ok {
|
||||
return genericMessages(stringOf(value))
|
||||
}
|
||||
if inner, role, ok := langChainMessage(m); ok {
|
||||
return chatMessage(inner, role)
|
||||
}
|
||||
if isLangGraphToolDefinition(m) {
|
||||
return nil
|
||||
}
|
||||
|
||||
typ := m.str("type")
|
||||
if isResponsesItemType(typ) {
|
||||
return responsesItemMessages(m, typ)
|
||||
}
|
||||
if typ == "reasoning" {
|
||||
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: reasoningParts(m)}}
|
||||
}
|
||||
|
||||
role := cmp.Or(normalizeRole(m.str("role")), standardRole(typ), defaultRole)
|
||||
msg := aiobservabilitytypes.Message{
|
||||
Role: role,
|
||||
Content: append(messageContentParts(m, role), messageToolCallParts(m)...),
|
||||
FinishReason: normalizeFinishReason(finishReasonOf(m)),
|
||||
}
|
||||
if refusal := m.str("refusal"); refusal != "" {
|
||||
msg.Content = append(msg.Content, textPart(refusal))
|
||||
}
|
||||
return []aiobservabilitytypes.Message{msg}
|
||||
}
|
||||
|
||||
// langChainMessage unwraps a serialised message: {lc, type: "constructor", id: [..., "HumanMessage"], kwargs}.
|
||||
func langChainMessage(m object) (object, aiobservabilitytypes.MessageRole, bool) {
|
||||
kwargs, ok := m.obj("kwargs")
|
||||
if !ok || m.str("type") != "constructor" {
|
||||
return nil, "", false
|
||||
}
|
||||
return kwargs, cmp.Or(langChainRole(m.at("id")), standardRole(kwargs.str("type"))), true
|
||||
}
|
||||
|
||||
func langChainRole(id any) aiobservabilitytypes.MessageRole {
|
||||
path, ok := id.([]any)
|
||||
if !ok || len(path) == 0 {
|
||||
return ""
|
||||
}
|
||||
switch class := stringOf(lastItem(path)); {
|
||||
case strings.HasPrefix(class, "System"):
|
||||
return aiobservabilitytypes.MessageRoleSystem
|
||||
case strings.HasPrefix(class, "Human"):
|
||||
return aiobservabilitytypes.MessageRoleUser
|
||||
case strings.HasPrefix(class, "AI"):
|
||||
return aiobservabilitytypes.MessageRoleAssistant
|
||||
case strings.HasPrefix(class, "Tool"), strings.HasPrefix(class, "Function"):
|
||||
return aiobservabilitytypes.MessageRoleTool
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isLangGraphToolDefinition matches {role: "tool", content: {type: "function"}} without tool_call_id.
|
||||
func isLangGraphToolDefinition(m object) bool {
|
||||
if normalizeRole(m.str("role")) != aiobservabilitytypes.MessageRoleTool {
|
||||
return false
|
||||
}
|
||||
if has := m.has("tool_call_id"); has {
|
||||
return false
|
||||
}
|
||||
content, ok := m.obj("content")
|
||||
if !ok || content.str("type") != "function" {
|
||||
return false
|
||||
}
|
||||
ok = content.has("function")
|
||||
return ok
|
||||
}
|
||||
|
||||
// messageContentParts turns a tool message's text into its result.
|
||||
func messageContentParts(m object, role aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Part {
|
||||
parts := []aiobservabilitytypes.Part{}
|
||||
switch content := m.at("content").(type) {
|
||||
case nil:
|
||||
case string:
|
||||
if role == aiobservabilitytypes.MessageRoleTool {
|
||||
parts = append(parts, toolMessageResult(m, content))
|
||||
} else if content != "" {
|
||||
parts = append(parts, textPart(content))
|
||||
}
|
||||
case []any:
|
||||
for _, item := range content {
|
||||
part := contentBlockPart(item)
|
||||
if role == aiobservabilitytypes.MessageRoleTool && part.Type == aiobservabilitytypes.PartTypeText {
|
||||
part = toolMessageResult(m, part.Content)
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
case map[string]any:
|
||||
if contentParts, ok := object(content).list("parts"); ok {
|
||||
for _, p := range contentParts {
|
||||
parts = append(parts, semconvPart(p))
|
||||
}
|
||||
} else if role == aiobservabilitytypes.MessageRoleTool {
|
||||
parts = append(parts, toolMessageResult(m, stringOf(content)))
|
||||
} else {
|
||||
parts = append(parts, genericPart(content))
|
||||
}
|
||||
default:
|
||||
parts = append(parts, genericPart(content))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func messageToolCallParts(m object) []aiobservabilitytypes.Part {
|
||||
calls, _ := m.first("tool_calls", "toolCalls").([]any)
|
||||
if kwargs, ok := m.obj("additional_kwargs"); ok && len(calls) == 0 {
|
||||
calls, _ = kwargs.list("tool_calls")
|
||||
}
|
||||
parts := make([]aiobservabilitytypes.Part, 0, len(calls)+1)
|
||||
for _, call := range calls {
|
||||
parts = append(parts, toolCallPart(call))
|
||||
}
|
||||
if call, ok := m.obj("function_call"); ok {
|
||||
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, Name: call.str("name"), Arguments: parseArguments(call.at("arguments"))})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func toolMessageResult(m object, content string) aiobservabilitytypes.Part {
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: stringOf(m.first("tool_call_id", "toolCallId")), Name: m.str("name"), Content: content}
|
||||
}
|
||||
|
||||
// semconvMessage falls back to chatMessage when m has no parts. LiteLLM mixes in
|
||||
// OpenAI fields: tool_calls beside parts, and tool_call_id on tool messages.
|
||||
func semconvMessage(m object, defaultRole aiobservabilitytypes.MessageRole) []aiobservabilitytypes.Message {
|
||||
parts, ok := m.list("parts")
|
||||
if !ok {
|
||||
return chatMessage(m, defaultRole)
|
||||
}
|
||||
role := cmp.Or(normalizeRole(m.str("role")), defaultRole)
|
||||
msg := aiobservabilitytypes.Message{
|
||||
Role: role,
|
||||
Content: []aiobservabilitytypes.Part{},
|
||||
FinishReason: normalizeFinishReason(finishReasonOf(m)),
|
||||
}
|
||||
for _, p := range parts {
|
||||
part := semconvPart(p)
|
||||
if role == aiobservabilitytypes.MessageRoleTool && part.Type == aiobservabilitytypes.PartTypeText {
|
||||
part = toolMessageResult(m, part.Content)
|
||||
}
|
||||
msg.Content = append(msg.Content, part)
|
||||
}
|
||||
msg.Content = append(msg.Content, messageToolCallParts(m)...)
|
||||
return []aiobservabilitytypes.Message{msg}
|
||||
}
|
||||
|
||||
// isResponsesItemType matches role-less Responses API tool and MCP items.
|
||||
func isResponsesItemType(typ string) bool {
|
||||
switch typ {
|
||||
case "":
|
||||
return false
|
||||
case "function_call", "function_call_output", "tool_call", "custom_tool_call", "custom_tool_call_output",
|
||||
"mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(typ, "_call") || strings.HasSuffix(typ, "_call_output")
|
||||
}
|
||||
|
||||
// responsesItemMessages turns a built-in tool item into a call, plus its result when the item carries one.
|
||||
func responsesItemMessages(m object, typ string) []aiobservabilitytypes.Message {
|
||||
switch typ {
|
||||
case "function_call", "tool_call", "custom_tool_call":
|
||||
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{{
|
||||
Type: aiobservabilitytypes.PartTypeToolCall,
|
||||
ID: stringOf(m.first("call_id", "id")),
|
||||
Name: m.str("name"),
|
||||
Arguments: parseArguments(m.first("arguments", "args", "input")),
|
||||
}}}}
|
||||
case "function_call_output", "custom_tool_call_output":
|
||||
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleTool, Content: []aiobservabilitytypes.Part{{
|
||||
Type: aiobservabilitytypes.PartTypeToolResult,
|
||||
ToolCallID: stringOf(m.first("call_id", "id")),
|
||||
Content: stringOf(m.first("output", "result")),
|
||||
}}}}
|
||||
}
|
||||
|
||||
id := stringOf(m.first("call_id", "id"))
|
||||
if strings.HasSuffix(typ, "_output") || typ == "mcp_approval_response" {
|
||||
return []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleTool, Content: []aiobservabilitytypes.Part{{
|
||||
Type: aiobservabilitytypes.PartTypeToolResult,
|
||||
ToolCallID: id,
|
||||
Name: strings.TrimSuffix(typ, "_output"),
|
||||
Content: stringOf(m.first("output", "result", "results")),
|
||||
}}}}
|
||||
}
|
||||
|
||||
args := make(map[string]any, len(m))
|
||||
for k, v := range m {
|
||||
switch k {
|
||||
case "type", "id", "call_id", "status", "name", "server_label", "output", "result", "results":
|
||||
default:
|
||||
args[k] = v
|
||||
}
|
||||
}
|
||||
name := stringOf(m.first("name", "server_label"))
|
||||
if name == "" {
|
||||
name = typ
|
||||
}
|
||||
call := aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, ID: id, Name: name}
|
||||
if arguments, ok := m.get("arguments"); ok {
|
||||
call.Arguments = parseArguments(arguments)
|
||||
} else if len(args) > 0 {
|
||||
call.Arguments = args
|
||||
}
|
||||
msg := aiobservabilitytypes.Message{Role: aiobservabilitytypes.MessageRoleAssistant, Content: []aiobservabilitytypes.Part{call}}
|
||||
if result := m.first("output", "result", "results"); result != nil {
|
||||
msg.Content = append(msg.Content, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: id, Name: name, Content: stringOf(result)})
|
||||
}
|
||||
return []aiobservabilitytypes.Message{msg}
|
||||
}
|
||||
|
||||
// reasoningParts returns one empty thinking part for encrypted reasoning without a summary.
|
||||
func reasoningParts(m object) []aiobservabilitytypes.Part {
|
||||
parts := []aiobservabilitytypes.Part{}
|
||||
if summary, ok := m.list("summary"); ok {
|
||||
for _, s := range summary {
|
||||
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(asObject(s).first("text", "content"))})
|
||||
}
|
||||
}
|
||||
if content, ok := m.list("content"); ok {
|
||||
for _, c := range content {
|
||||
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(asObject(c).first("text", "content"))})
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func textMessage(role aiobservabilitytypes.MessageRole, content string) aiobservabilitytypes.Message {
|
||||
return aiobservabilitytypes.Message{Role: role, Content: []aiobservabilitytypes.Part{textPart(content)}}
|
||||
}
|
||||
|
||||
func genericMessages(content string) []aiobservabilitytypes.Message {
|
||||
return []aiobservabilitytypes.Message{{Content: []aiobservabilitytypes.Part{genericPart(content)}}}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
func TestNormalizeMessageChat(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "AnthropicMessages_ThinkingAndToolUseBlocksConverted",
|
||||
raw: `[
|
||||
{"role": "user", "content": "Hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me see"},
|
||||
{"type": "redacted_thinking", "data": "x"},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "lookup",
|
||||
"input": {"q": "a"}
|
||||
}
|
||||
],
|
||||
"stop_reason": "tool_use"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": "found",
|
||||
"is_error": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
FinishReason: aiobservabilitytypes.FinishReasonToolCall,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me see"},
|
||||
{Type: aiobservabilitytypes.PartTypeThinking},
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "toolu_1", Name: "lookup", Arguments: map[string]any{"q": "a"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "toolu_1", Content: "found", IsError: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAIToolMessageTextBlocks_BecomeToolResult",
|
||||
raw: `[
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1",
|
||||
"content": [{"type": "text", "text": "20C"}, {"type": "text", "text": "clear"}]
|
||||
}
|
||||
]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleTool,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "c1", Content: "20C"},
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "c1", Content: "clear"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAIRefusal_BecomesAssistantText",
|
||||
raw: `[{"role": "assistant", "content": null, "refusal": "I can't help with that."}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "I can't help with that."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenAILegacyFunctionCall_BecomesToolCall",
|
||||
raw: `[{"role": "assistant", "content": null, "function_call": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeMessageLangChain(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "LangChainTypedMessages_ToolCallsReadFromAdditionalKwargs",
|
||||
raw: `[
|
||||
{"type": "human", "content": "Weather?"},
|
||||
{
|
||||
"type": "ai",
|
||||
"content": "",
|
||||
"additional_kwargs": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool",
|
||||
"content": "20C",
|
||||
"tool_call_id": "call_1",
|
||||
"name": "get_weather"
|
||||
}
|
||||
]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Weather?"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "call_1", Name: "get_weather", Arguments: map[string]any{"city": "Paris"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleTool,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_1", Name: "get_weather", Content: "20C"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LangGraphToolDefinitionMessage_IsDropped",
|
||||
raw: `[
|
||||
{
|
||||
"role": "tool",
|
||||
"content": {
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "parameters": {}}
|
||||
}
|
||||
},
|
||||
{"role": "user", "content": "Hi"}
|
||||
]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Hi"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeMessageOpenAIResponsesItems(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "BuiltInToolCallItem_BecomesToolCall",
|
||||
raw: `{
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "web_search_call",
|
||||
"id": "ws_1",
|
||||
"status": "completed",
|
||||
"action": {"type": "search", "query": "SigNoz"}
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "c1",
|
||||
"name": "grep",
|
||||
"input": "foo"
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Found it."}]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "ws_1", Name: "web_search_call", Arguments: map[string]any{"action": map[string]any{"type": "search", "query": "SigNoz"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "c1", Name: "grep", Arguments: "foo"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Found it."},
|
||||
},
|
||||
FinishReason: aiobservabilitytypes.FinishReasonStop,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BuiltInToolOutputItem_BecomesToolResult",
|
||||
raw: `[{"type": "local_shell_call_output", "call_id": "call_2", "output": "ok"}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleTool,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_2", Name: "local_shell_call", Content: "ok"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "MCPCallItemWithOutput_BecomesToolCallAndToolResult",
|
||||
raw: `[{"type": "mcp_call", "id": "mcp_1", "server_label": "deepwiki", "name": "ask_question", "arguments": "{\"q\": \"What is SigNoz?\"}", "output": "An observability platform."}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "mcp_1", Name: "ask_question", Arguments: map[string]any{"q": "What is SigNoz?"}},
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "mcp_1", Name: "ask_question", Content: "An observability platform."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
func contentBlockPart(value any) aiobservabilitytypes.Part {
|
||||
p, ok := toObject(value)
|
||||
if !ok {
|
||||
if s, ok := value.(string); ok {
|
||||
return textPart(s)
|
||||
}
|
||||
return genericPart(value)
|
||||
}
|
||||
typ := p.str("type")
|
||||
switch typ {
|
||||
case "text", "input_text", "output_text", "refusal", "summary_text":
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(p.first("text", "content", "refusal"))}
|
||||
case "thinking", "reasoning":
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("thinking", "text", "content", "reasoning"))}
|
||||
case "redacted_thinking":
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking}
|
||||
case "tool-call", "tool_use", "tool_call", "function_call", "server_tool_use", "mcp_tool_use":
|
||||
return toolCallPart(p)
|
||||
case "tool-result", "tool_result", "function_call_output":
|
||||
return toolResultBlockPart(p, p.first("result", "output", "content"))
|
||||
case "":
|
||||
if text, ok := p.get("text"); ok {
|
||||
return textPart(stringOf(text))
|
||||
}
|
||||
default:
|
||||
if strings.HasSuffix(typ, "_tool_result") {
|
||||
return toolResultBlockPart(p, p.at("content"))
|
||||
}
|
||||
}
|
||||
return genericPart(p)
|
||||
}
|
||||
|
||||
func semconvPart(value any) aiobservabilitytypes.Part {
|
||||
p, ok := toObject(value)
|
||||
if !ok {
|
||||
if s, ok := value.(string); ok {
|
||||
return textPart(s)
|
||||
}
|
||||
return genericPart(value)
|
||||
}
|
||||
switch p.str("type") {
|
||||
case "text":
|
||||
if p.flag("thought") {
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("content", "text"))}
|
||||
}
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(p.first("content", "text"))}
|
||||
case "reasoning", "thinking":
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(p.first("content", "thinking", "text"))}
|
||||
case "redacted_thinking", "redacted_reasoning":
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking}
|
||||
case "tool_call":
|
||||
return aiobservabilitytypes.Part{
|
||||
Type: aiobservabilitytypes.PartTypeToolCall,
|
||||
ID: p.str("id"),
|
||||
Name: p.str("name"),
|
||||
Arguments: parseArguments(p.first("arguments", "args", "input")),
|
||||
}
|
||||
case "tool_call_response":
|
||||
return aiobservabilitytypes.Part{
|
||||
Type: aiobservabilitytypes.PartTypeToolResult,
|
||||
ToolCallID: p.str("id"),
|
||||
Name: p.str("name"),
|
||||
Content: stringOf(p.first("response", "result", "content", "output")),
|
||||
IsError: boolOf(p.first("is_error", "isError")),
|
||||
}
|
||||
case "":
|
||||
// Gemini parts carry no type; the field name is the type.
|
||||
if text, ok := p.get("text"); ok {
|
||||
if p.flag("thought") {
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeThinking, Content: stringOf(text)}
|
||||
}
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: stringOf(text)}
|
||||
}
|
||||
if call, ok := toObject(p.first("functionCall", "function_call")); ok {
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolCall, ID: call.str("id"), Name: call.str("name"), Arguments: parseArguments(call.at("args"))}
|
||||
}
|
||||
if resp, ok := toObject(p.first("functionResponse", "function_response")); ok {
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: resp.str("id"), Name: resp.str("name"), Content: resp.str("response")}
|
||||
}
|
||||
}
|
||||
return genericPart(p)
|
||||
}
|
||||
|
||||
// toolCallPart reads the OpenAI, flat, Anthropic and Vercel tool call shapes.
|
||||
func toolCallPart(value any) aiobservabilitytypes.Part {
|
||||
call, ok := toObject(value)
|
||||
if !ok {
|
||||
return genericPart(value)
|
||||
}
|
||||
part := aiobservabilitytypes.Part{
|
||||
Type: aiobservabilitytypes.PartTypeToolCall,
|
||||
ID: stringOf(call.first("toolCallId", "call_id", "id")),
|
||||
Name: stringOf(call.first("toolName", "name")),
|
||||
}
|
||||
if fn, ok := call.obj("function"); ok {
|
||||
part.Name = fn.str("name")
|
||||
part.Arguments = parseArguments(fn.at("arguments"))
|
||||
return part
|
||||
}
|
||||
part.Arguments = parseArguments(call.first("arguments", "args", "input"))
|
||||
return part
|
||||
}
|
||||
|
||||
// toolResultBlockPart unwraps the Vercel {type, value} result wrapper.
|
||||
func toolResultBlockPart(p object, result any) aiobservabilitytypes.Part {
|
||||
if nested, ok := toObject(result); ok && len(nested) <= 2 {
|
||||
if v, ok := nested.get("value"); ok {
|
||||
result = v
|
||||
}
|
||||
}
|
||||
return aiobservabilitytypes.Part{
|
||||
Type: aiobservabilitytypes.PartTypeToolResult,
|
||||
ToolCallID: stringOf(p.first("toolCallId", "tool_use_id", "tool_call_id", "call_id", "id")),
|
||||
Name: stringOf(p.first("toolName", "name")),
|
||||
Content: toolResultText(result),
|
||||
IsError: boolOf(p.first("isError", "is_error")),
|
||||
}
|
||||
}
|
||||
|
||||
// toolResultText joins a list of text blocks; anything else goes through stringOf.
|
||||
func toolResultText(result any) string {
|
||||
list, ok := result.([]any)
|
||||
if !ok || len(list) == 0 {
|
||||
return stringOf(result)
|
||||
}
|
||||
texts := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
block := asObject(item)
|
||||
text, ok := block.text("text")
|
||||
if !ok || (len(block) == 2 && block.str("type") != "text") || len(block) > 2 {
|
||||
return stringOf(result)
|
||||
}
|
||||
texts = append(texts, text)
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
|
||||
func textPart(content string) aiobservabilitytypes.Part {
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeText, Content: content}
|
||||
}
|
||||
|
||||
func genericPart(value any) aiobservabilitytypes.Part {
|
||||
return aiobservabilitytypes.Part{Type: aiobservabilitytypes.PartTypeGeneric, Content: stringOf(value)}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
func TestNormalizePartContent(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "PartWithUnknownType_BecomesGeneric",
|
||||
raw: `[{"role":"user","parts":[{"type":"image","url":"http://x/y.png"}]}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeGeneric, Content: `{"type":"image","url":"http://x/y.png"}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizePartThinking(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "ThoughtReasoningAndHiddenParts_BecomeThinking",
|
||||
raw: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{"type": "text", "content": "Let me think", "thought": true},
|
||||
{"type": "reasoning", "content": "step 1"},
|
||||
{"type": "redacted_reasoning", "data": "xyz"},
|
||||
{"type": "text", "content": "Answer"}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeThinking, Content: "Let me think"},
|
||||
{Type: aiobservabilitytypes.PartTypeThinking, Content: "step 1"},
|
||||
{Type: aiobservabilitytypes.PartTypeThinking},
|
||||
{Type: aiobservabilitytypes.PartTypeText, Content: "Answer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizePartTools(t *testing.T) {
|
||||
assertNormalize(t, []normalizeCase{
|
||||
{
|
||||
name: "AnthropicServerToolUseAndWebSearchResult_BecomeToolCallAndResult",
|
||||
raw: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "server_tool_use", "id": "srvtoolu_1", "name": "web_search", "input": {"query": "SigNoz"}},
|
||||
{"type": "web_search_tool_result", "tool_use_id": "srvtoolu_1", "content": [{"type": "web_search_result", "title": "SigNoz", "url": "https://signoz.io"}]}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "srvtoolu_1", Name: "web_search", Arguments: map[string]any{"query": "SigNoz"}},
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "srvtoolu_1", Content: `[{"title":"SigNoz","type":"web_search_result","url":"https://signoz.io"}]`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AnthropicToolResultTextBlocks_JoinedIntoOneString",
|
||||
raw: `[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "t1",
|
||||
"content": [
|
||||
{"type": "text", "text": "line one"},
|
||||
{"type": "text", "text": "line two"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "t1", Content: "line one\nline two"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "VercelToolResultJSONWrapper_ValueUnwrapped",
|
||||
raw: `[{"role": "tool", "content": [{"type": "tool-result", "toolCallId": "call_1", "toolName": "get_weather", "result": {"type": "json", "value": {"temp_c": 18}}}]}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleTool,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolResult, ToolCallID: "call_1", Name: "get_weather", Content: `{"temp_c":18}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ToolCallWithInvalidJSONArguments_ArgumentsKeptAsString",
|
||||
raw: `[{"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{city: Paris"}}]}]`,
|
||||
want: []aiobservabilitytypes.Message{
|
||||
{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{
|
||||
{Type: aiobservabilitytypes.PartTypeToolCall, ID: "call_1", Name: "get_weather", Arguments: "{city: Paris"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
var finishReasonKeys = []string{"finish_reason", "finishReason", "stop_reason"}
|
||||
|
||||
// normalizeRole keeps an unknown role, lowercased.
|
||||
func normalizeRole(role string) aiobservabilitytypes.MessageRole {
|
||||
if known := standardRole(role); known != "" {
|
||||
return known
|
||||
}
|
||||
return aiobservabilitytypes.MessageRole(strings.ToLower(strings.TrimSpace(role)))
|
||||
}
|
||||
|
||||
// normalizeFinishReason keeps an unknown reason, lowercased.
|
||||
func normalizeFinishReason(reason string) aiobservabilitytypes.FinishReason {
|
||||
lowered := strings.ToLower(strings.TrimSpace(reason))
|
||||
switch lowered {
|
||||
case "stop", "end_turn", "stop_sequence", "completed", "complete", "eos", "finished":
|
||||
return aiobservabilitytypes.FinishReasonStop
|
||||
case "tool_call", "tool_calls", "tool_use", "function_call":
|
||||
return aiobservabilitytypes.FinishReasonToolCall
|
||||
case "length", "max_tokens", "max_output_tokens", "max_completion_tokens", "model_length":
|
||||
return aiobservabilitytypes.FinishReasonLength
|
||||
case "content_filter", "content_filtered", "guardrail_intervened", "safety", "refusal", "recitation", "blocklist", "prohibited_content", "spii":
|
||||
return aiobservabilitytypes.FinishReasonContentFilter
|
||||
case "error", "failed", "incomplete":
|
||||
return aiobservabilitytypes.FinishReasonError
|
||||
}
|
||||
return aiobservabilitytypes.FinishReason(lowered)
|
||||
}
|
||||
|
||||
// standardRole maps vendor role names onto MessageRole; anything else is "".
|
||||
func standardRole(role string) aiobservabilitytypes.MessageRole {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "system", "developer":
|
||||
return aiobservabilitytypes.MessageRoleSystem
|
||||
case "user", "human":
|
||||
return aiobservabilitytypes.MessageRoleUser
|
||||
case "assistant", "ai", "model":
|
||||
return aiobservabilitytypes.MessageRoleAssistant
|
||||
case "tool", "function":
|
||||
return aiobservabilitytypes.MessageRoleTool
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func finishReasonOf(m object) string {
|
||||
return stringOf(m.first(finishReasonKeys...))
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeRole(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
roles []string
|
||||
want aiobservabilitytypes.MessageRole
|
||||
}{
|
||||
{name: "SystemAndDeveloper_MapToSystem", roles: []string{"system", "developer", " Developer "}, want: aiobservabilitytypes.MessageRoleSystem},
|
||||
{name: "UserAndHuman_MapToUser", roles: []string{"user", "human", "HUMAN"}, want: aiobservabilitytypes.MessageRoleUser},
|
||||
{name: "AssistantAIAndModel_MapToAssistant", roles: []string{"assistant", "ai", "model"}, want: aiobservabilitytypes.MessageRoleAssistant},
|
||||
{name: "ToolAndFunction_MapToTool", roles: []string{"tool", "function"}, want: aiobservabilitytypes.MessageRoleTool},
|
||||
{name: "UnknownRole_KeptLowercased", roles: []string{"Narrator"}, want: "narrator"},
|
||||
{name: "EmptyRole_StaysEmpty", roles: []string{"", " "}, want: ""},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for _, role := range testCase.roles {
|
||||
assert.Equal(t, testCase.want, normalizeRole(role), role)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFinishReason(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
reasons []string
|
||||
want aiobservabilitytypes.FinishReason
|
||||
}{
|
||||
{name: "EndOfAnswer_MapsToStop", reasons: []string{"stop", "end_turn", "stop_sequence", "completed", "complete", "eos", "finished", "STOP"}, want: aiobservabilitytypes.FinishReasonStop},
|
||||
{name: "ToolCallVariants_MapToToolCall", reasons: []string{"tool_call", "tool_calls", "tool_use", "function_call"}, want: aiobservabilitytypes.FinishReasonToolCall},
|
||||
{name: "TokenLimits_MapToLength", reasons: []string{"length", "max_tokens", "max_output_tokens", "max_completion_tokens", "model_length", "MAX_TOKENS"}, want: aiobservabilitytypes.FinishReasonLength},
|
||||
{name: "SafetyBlocks_MapToContentFilter", reasons: []string{"content_filter", "content_filtered", "guardrail_intervened", "safety", "refusal", "recitation", "blocklist", "prohibited_content", "spii", "SAFETY"}, want: aiobservabilitytypes.FinishReasonContentFilter},
|
||||
{name: "Failures_MapToError", reasons: []string{"error", "failed", "incomplete"}, want: aiobservabilitytypes.FinishReasonError},
|
||||
{name: "UnknownReason_KeptLowercased", reasons: []string{"Weird"}, want: "weird"},
|
||||
{name: "EmptyReason_StaysEmpty", reasons: []string{"", " "}, want: ""},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for _, reason := range testCase.reasons {
|
||||
assert.Equal(t, testCase.want, normalizeFinishReason(reason), reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."},{"role":"assistant","content":"","tool_calls":[{"id":"call_yGnJsGOZMnHKTA2x1XM8897b","type":"function","name":"get_current_weather","args":"{\"city\":\"Bengaluru\"}"}]},{"role":"tool","content":"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]
|
||||
@@ -1,33 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_yGnJsGOZMnHKTA2x1XM8897b",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role":"assistant","content":"","tool_calls":[{"id":"call_yGnJsGOZMnHKTA2x1XM8897b","type":"function","name":"get_current_weather","args":"{\"city\":\"Bengaluru\"}"}]}]
|
||||
@@ -1,15 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_yGnJsGOZMnHKTA2x1XM8897b",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_iXrmKomEbGycID5aX4BYrQcg",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
|
||||
"toolCallId": "call_iXrmKomEbGycID5aX4BYrQcg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_iXrmKomEbGycID5aX4BYrQcg", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_iXrmKomEbGycID5aX4BYrQcg",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru?"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}"}]}]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_mnRhZ0fYlvrSujkEcdmpppNw",
|
||||
"name": "get_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
|
||||
"toolCallId": "call_mnRhZ0fYlvrSujkEcdmpppNw"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "assistant", "parts": [{"type": "tool_call", "id": "call_mnRhZ0fYlvrSujkEcdmpppNw", "name": "get_weather", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_mnRhZ0fYlvrSujkEcdmpppNw",
|
||||
"name": "get_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"generations":[[{"text":"","generation_info":{"finish_reason":"tool_calls","logprobs":null},"type":"ChatGeneration","message":{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default","finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a-d27a-7c23-b1ed-a542e0f855da-0","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"usage_metadata":{"input_tokens":63,"output_tokens":17,"total_tokens":80,"input_token_details":{"audio":0,"cache_read":0},"output_token_details":{"audio":0,"reasoning":0}},"invalid_tool_calls":[]}}}]],"llm_output":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default"},"run":null,"type":"LLMResult"}
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_nbrbkc5fom95q8uMA2bCvH6s",
|
||||
"name": "get_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"messages":[[{"lc":1,"type":"constructor","id":["langchain","schema","messages","SystemMessage"],"kwargs":{"content":"You are a weather assistant. Call get_weather once per city.","type":"system"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","HumanMessage"],"kwargs":{"content":"What's the weather in Bengaluru?","type":"human"}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","AIMessage"],"kwargs":{"content":"","additional_kwargs":{"refusal":null},"response_metadata":{"token_usage":{"completion_tokens":17,"prompt_tokens":63,"total_tokens":80,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}},"model_provider":"openai","model_name":"gpt-4o-mini-2024-07-18","system_fingerprint":"fp_5feb5fe6c2","id":"chatcmpl-Dm101OqaY01WKntqj29Epz5er05ry","service_tier":"default","finish_reason":"tool_calls","logprobs":null},"type":"ai","id":"lc_run--019e844a-d27a-7c23-b1ed-a542e0f855da-0","tool_calls":[{"name":"get_weather","args":{"city":"Bengaluru"},"id":"call_nbrbkc5fom95q8uMA2bCvH6s","type":"tool_call"}],"usage_metadata":{"input_tokens":63,"output_tokens":17,"total_tokens":80,"input_token_details":{"audio":0,"cache_read":0},"output_token_details":{"audio":0,"reasoning":0}},"invalid_tool_calls":[]}},{"lc":1,"type":"constructor","id":["langchain","schema","messages","ToolMessage"],"kwargs":{"content":"{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}","type":"tool","tool_call_id":"call_nbrbkc5fom95q8uMA2bCvH6s","status":"success"}}]]}
|
||||
@@ -1,43 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "You are a weather assistant. Call get_weather once per city."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_nbrbkc5fom95q8uMA2bCvH6s",
|
||||
"name": "get_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
|
||||
"toolCallId": "call_nbrbkc5fom95q8uMA2bCvH6s"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "user", "parts": [{"type": "text", "content": "What's the weather in Bengaluru? Use the tool."}]}, {"role": "assistant", "parts": [], "tool_calls": [{"function": {"arguments": "{\"city\":\"Bengaluru\"}", "name": "get_current_weather"}, "id": "call_GuU9siyYfjnx3DfHKzFbRrvD", "type": "function"}]}, {"role": "tool", "parts": [{"type": "text", "content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}], "tool_call_id": "call_GuU9siyYfjnx3DfHKzFbRrvD"}]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_GuU9siyYfjnx3DfHKzFbRrvD",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
|
||||
"toolCallId": "call_GuU9siyYfjnx3DfHKzFbRrvD"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a cartoon duck."}], "finish_reason": "stop"}]
|
||||
@@ -1,12 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "The animal in the image is a cartoon duck."
|
||||
}
|
||||
],
|
||||
"finishReason": "stop"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_itnlpgQcfe0fb0nLJym7DTnt",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
|
||||
"toolCallId": "call_itnlpgQcfe0fb0nLJym7DTnt"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_itnlpgQcfe0fb0nLJym7DTnt", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_itnlpgQcfe0fb0nLJym7DTnt",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role":"system","content":"You are a weather assistant. When asked about weather, call get-weather once per city and summarise."},{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru?"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":{"city":"Bengaluru"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","result":{"city":"Bengaluru","temperatureC":18,"summary":"Clear"}}]}]
|
||||
@@ -1,44 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "You are a weather assistant. When asked about weather, call get-weather once per city and summarise."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What is the weather in Bengaluru?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_526phoykytobsvLouzES805J",
|
||||
"name": "getWeather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\":\"Bengaluru\",\"summary\":\"Clear\",\"temperatureC\":18}",
|
||||
"name": "getWeather",
|
||||
"toolCallId": "call_526phoykytobsvLouzES805J"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
The current weather in Bengaluru is clear with a temperature of 18°C.
|
||||
@@ -1,10 +0,0 @@
|
||||
[
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "The current weather in Bengaluru is clear with a temperature of 18°C."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"toolCallType":"function","toolCallId":"call_526phoykytobsvLouzES805J","toolName":"getWeather","args":"{\"city\":\"Bengaluru\"}"}]
|
||||
@@ -1,15 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_526phoykytobsvLouzES805J",
|
||||
"name": "getWeather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"include": [], "input": [{"content": "What's the weather in Bangalore right now?", "role": "user"}, {"arguments": "{\"city\":\"Bangalore\"}", "call_id": "call_MQTQ0XYL95vA5k31cbCgRt5y", "name": "get_weather", "type": "function_call", "id": "fc_0368c3e62fb9ae12006a199f7c14c481a1a22444594cc574e4", "status": "completed"}, {"call_id": "call_MQTQ0XYL95vA5k31cbCgRt5y", "output": "27C, partly cloudy, light breeze from the south.", "type": "function_call_output"}], "instructions": "You are a concise weather assistant. When asked about weather, call the get_weather tool exactly once per city, then summarise the result in one sentence.", "model": "gpt-4o-mini", "tools": [{"name": "get_weather", "parameters": {"properties": {"city": {"title": "City", "type": "string"}}, "required": ["city"], "title": "get_weather_args", "type": "object", "additionalProperties": false}, "strict": true, "type": "function", "description": "Look up the current weather for a city. Returns a short summary string."}]}
|
||||
@@ -1,43 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "You are a concise weather assistant. When asked about weather, call the get_weather tool exactly once per city, then summarise the result in one sentence."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bangalore right now?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_MQTQ0XYL95vA5k31cbCgRt5y",
|
||||
"name": "get_weather",
|
||||
"arguments": {
|
||||
"city": "Bangalore"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "27C, partly cloudy, light breeze from the south.",
|
||||
"toolCallId": "call_MQTQ0XYL95vA5k31cbCgRt5y"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"id":"resp_017c5eb995d99968006a199f9b483481979ca790db905506fc","created_at":1780064155.0,"error":null,"incomplete_details":null,"instructions":"You solve problems step by step and give a final answer.","metadata":{},"model":"o3-mini-2025-01-31","object":"response","output":[{"id":"rs_017c5eb995d99968006a199f9bff248197978bf9193f34c29a","summary":[],"type":"reasoning"},{"id":"msg_017c5eb995d99968006a199f9c8dcc81979f4e10afbe5350a1","content":[{"annotations":[],"text":"Step 1: Let the cost of the ball be x dollars.\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\n x + (x + 1) = 1.10\nStep 4: Simplify the equation:\n 2x + 1 = 1.10\nStep 5: Subtract 1 from both sides:\n 2x = 0.10\nStep 6: Divide both sides by 2:\n x = 0.05\n\nFinal Answer: The ball costs $0.05.","type":"output_text","logprobs":[]}],"role":"assistant","status":"completed","type":"message"}],"parallel_tool_calls":true,"temperature":1.0,"tool_choice":"auto","tools":[],"top_p":1.0,"background":false,"completed_at":1780064157.0,"max_output_tokens":null,"max_tool_calls":null,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"effort":"medium","summary":null,"context":"current_turn"},"safety_identifier":null,"service_tier":"default","status":"completed","text":{"format":{"type":"text"},"verbosity":"medium"},"top_logprobs":0,"truncation":"disabled","usage":{"input_tokens":49,"input_tokens_details":{"cached_tokens":0},"output_tokens":431,"output_tokens_details":{"reasoning_tokens":128},"total_tokens":480},"user":null,"billing":{"payer":"developer"},"frequency_penalty":0.0,"moderation":null,"presence_penalty":0.0,"store":true}
|
||||
@@ -1,20 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Step 1: Let the cost of the ball be x dollars.\nStep 2: Since the bat costs $1 more than the ball, its cost is x + 1 dollars.\nStep 3: According to the problem, the total cost of the bat and ball is $1.10. Set up the equation:\n x + (x + 1) = 1.10\nStep 4: Simplify the equation:\n 2x + 1 = 1.10\nStep 5: Subtract 1 from both sides:\n 2x = 0.10\nStep 6: Divide both sides by 2:\n x = 0.05\n\nFinal Answer: The ball costs $0.05."
|
||||
}
|
||||
],
|
||||
"finishReason": "stop"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"messages": [{"role": "user", "content": "What's the weather in Paris and in London? Use the tool for each."}, {"content": null, "refusal": null, "role": "assistant", "annotations": [], "audio": null, "function_call": null, "tool_calls": [{"id": "call_SW4hst7nzysgvWSAz4OdyK9D", "function": {"arguments": "{\"city\": \"Paris\"}", "name": "get_current_weather"}, "type": "function"}, {"id": "call_si8wiETYGpooMB7gMXSgMQpk", "function": {"arguments": "{\"city\": \"London\"}", "name": "get_current_weather"}, "type": "function"}]}, {"role": "tool", "tool_call_id": "call_SW4hst7nzysgvWSAz4OdyK9D", "content": "{\"city\": \"Paris\", \"temp_c\": 18, \"summary\": \"Clear\"}"}, {"role": "tool", "tool_call_id": "call_si8wiETYGpooMB7gMXSgMQpk", "content": "{\"city\": \"London\", \"temp_c\": 18, \"summary\": \"Clear\"}"}], "model": "gpt-4o-mini", "max_tokens": 80}
|
||||
@@ -1,52 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Paris and in London? Use the tool for each."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_SW4hst7nzysgvWSAz4OdyK9D",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Paris"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_si8wiETYGpooMB7gMXSgMQpk",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "London"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Paris\", \"temp_c\": 18, \"summary\": \"Clear\"}",
|
||||
"toolCallId": "call_SW4hst7nzysgvWSAz4OdyK9D"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"London\", \"temp_c\": 18, \"summary\": \"Clear\"}",
|
||||
"toolCallId": "call_si8wiETYGpooMB7gMXSgMQpk"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"id":"chatcmpl-DksB6cYbMPwTBkJixOAuxKGZpQKds","choices":[{"finish_reason":"tool_calls","index":0,"logprobs":null,"message":{"content":null,"refusal":null,"role":"assistant","annotations":[],"tool_calls":[{"id":"call_MdYKTRrkshuBG9W5oBXYZaRc","function":{"arguments":"{\"city\":\"Bengaluru\",\"unit\":\"c\"}","name":"get_current_weather"},"type":"function"}]}}],"created":1780063720,"model":"gpt-4o-mini-2024-07-18","object":"chat.completion","service_tier":"default","system_fingerprint":"fp_c6aa0586c9","usage":{"completion_tokens":22,"prompt_tokens":65,"total_tokens":87,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0}}}
|
||||
@@ -1,17 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_MdYKTRrkshuBG9W5oBXYZaRc",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru",
|
||||
"unit": "c"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_TaqNOsbmsz4ifWLVyDHYAAEK",
|
||||
"name": "get_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
|
||||
"toolCallId": "call_TaqNOsbmsz4ifWLVyDHYAAEK"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_weather", "id": "call_TaqNOsbmsz4ifWLVyDHYAAEK", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_TaqNOsbmsz4ifWLVyDHYAAEK",
|
||||
"name": "get_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"completion":"Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\n\n x + (x + 1) = 1.10\n\nCombine like terms:\n\n 2x + 1 = 1.10\n\nSubtract 1 from both sides:\n\n 2x = 0.10\n\nDivide both sides by 2:\n\n x = 0.05\n\nSo, the ball costs 5 cents.","reasoning":"**Calculating ball price**\n\nI'm determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10.","rawRequest":{"model":"openai/o3-mini","max_completion_tokens":2000}}
|
||||
@@ -1,15 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"content": "**Calculating ball price**\n\nI'm determining the price of the ball based on a scenario where a bat and ball combined cost $1.10, with the bat costing $1 more than the ball. I set up the equation: let the cost of the ball be x, making the bat x + 1. Solving the equation x + (x + 1) = 1.10 leads me to find that x equals $0.05 for the ball. I need to confirm that this works: the ball at $0.05 and the bat at $1.05 totals $1.10."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Let x be the cost of the ball. Then the bat costs x + 1 dollars. The total cost is given as:\n\n x + (x + 1) = 1.10\n\nCombine like terms:\n\n 2x + 1 = 1.10\n\nSubtract 1 from both sides:\n\n 2x = 0.10\n\nDivide both sides by 2:\n\n x = 0.05\n\nSo, the ball costs 5 cents."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
{"messages":[{"role":"user","content":"What's the weather in Bengaluru? Use the tool."},{"content":null,"refusal":null,"role":"assistant","annotations":null,"audio":null,"function_call":null,"tool_calls":[{"id":"call_AwiGdiFjghGVhbmbGtt92rjs","function":{"arguments":"{\"city\":\"Bengaluru\"}","name":"get_current_weather"},"type":"function","index":0}],"reasoning":null},{"role":"tool","tool_call_id":"call_AwiGdiFjghGVhbmbGtt92rjs","content":"{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_AwiGdiFjghGVhbmbGtt92rjs",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
|
||||
"toolCallId": "call_AwiGdiFjghGVhbmbGtt92rjs"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_4BfNpuiPppRa5N1LQIkuBknW", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_4BfNpuiPppRa5N1LQIkuBknW", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_4BfNpuiPppRa5N1LQIkuBknW",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
|
||||
"toolCallId": "call_4BfNpuiPppRa5N1LQIkuBknW"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_4BfNpuiPppRa5N1LQIkuBknW", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_4BfNpuiPppRa5N1LQIkuBknW",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "user", "parts": [{"content": "What's the weather in Bengaluru? Use the tool.", "type": "text"}]}, {"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_dYNkfcNlSN2A0TPAkSGXDPJb", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_dYNkfcNlSN2A0TPAkSGXDPJb", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}"}]}]
|
||||
@@ -1,34 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Bengaluru? Use the tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_dYNkfcNlSN2A0TPAkSGXDPJb",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\": \"Bengaluru\", \"temp_c\": 28, \"summary\": \"Partly cloudy\"}",
|
||||
"toolCallId": "call_dYNkfcNlSN2A0TPAkSGXDPJb"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role": "assistant", "parts": [{"type": "tool_call", "name": "get_current_weather", "id": "call_dYNkfcNlSN2A0TPAkSGXDPJb", "arguments": {"city": "Bengaluru"}}], "finish_reason": "tool_call"}]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_dYNkfcNlSN2A0TPAkSGXDPJb",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finishReason": "tool_call"
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"role":"user","content":[{"type":"text","text":"What is the weather in Bengaluru in celsius?"}]},{"role":"assistant","content":[{"type":"tool-call","toolCallId":"call_l0ycKcmt1uPCdGWR4SkwoLBv","toolName":"get_current_weather","args":{"city":"Bengaluru","unit":"c"}}]},{"role":"tool","content":[{"type":"tool-result","toolCallId":"call_l0ycKcmt1uPCdGWR4SkwoLBv","toolName":"get_current_weather","result":{"city":"Bengaluru","unit":"c","temperature":27,"condition":"partly cloudy"}}]}]
|
||||
@@ -1,36 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What is the weather in Bengaluru in celsius?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_l0ycKcmt1uPCdGWR4SkwoLBv",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru",
|
||||
"unit": "c"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "{\"city\":\"Bengaluru\",\"condition\":\"partly cloudy\",\"temperature\":27,\"unit\":\"c\"}",
|
||||
"name": "get_current_weather",
|
||||
"toolCallId": "call_l0ycKcmt1uPCdGWR4SkwoLBv"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
Hello! How are you today?
|
||||
@@ -1,10 +0,0 @@
|
||||
[
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Hello! How are you today?"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[{"type":"tool-call","toolCallId":"call_l0ycKcmt1uPCdGWR4SkwoLBv","toolName":"get_current_weather","args":{"city":"Bengaluru","unit":"c"}}]
|
||||
@@ -1,16 +0,0 @@
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"id": "call_l0ycKcmt1uPCdGWR4SkwoLBv",
|
||||
"name": "get_current_weather",
|
||||
"arguments": {
|
||||
"city": "Bengaluru",
|
||||
"unit": "c"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,160 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
)
|
||||
|
||||
// parseArguments decodes JSON-encoded arguments; anything else is returned as is.
|
||||
func parseArguments(value any) any {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return s
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func decodeJSONStrings(list []any) ([]any, bool) {
|
||||
out := make([]any, 0, len(list))
|
||||
for _, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil || decoded == nil {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, decoded)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func flattenOnce(list []any) []any {
|
||||
out := make([]any, 0, len(list))
|
||||
for _, item := range list {
|
||||
if inner, ok := item.([]any); ok {
|
||||
out = append(out, inner...)
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// object is a decoded JSON object. Its accessors return zero values for missing
|
||||
// keys and for values of another type, so reads never panic.
|
||||
type object map[string]any
|
||||
|
||||
func toObject(value any) (object, bool) {
|
||||
switch v := value.(type) {
|
||||
case object:
|
||||
return v, true
|
||||
case map[string]any:
|
||||
return v, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func asObject(value any) object {
|
||||
m, _ := toObject(value)
|
||||
return m
|
||||
}
|
||||
|
||||
func (o object) at(key string) any {
|
||||
return o[key]
|
||||
}
|
||||
|
||||
func (o object) get(key string) (any, bool) {
|
||||
value, ok := o[key]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
func (o object) has(key string) bool {
|
||||
_, ok := o[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (o object) str(key string) string {
|
||||
return stringOf(o[key])
|
||||
}
|
||||
|
||||
func (o object) flag(key string) bool {
|
||||
return boolOf(o[key])
|
||||
}
|
||||
|
||||
func (o object) text(key string) (string, bool) {
|
||||
s, ok := o[key].(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func (o object) obj(key string) (object, bool) {
|
||||
return toObject(o[key])
|
||||
}
|
||||
|
||||
func (o object) list(key string) ([]any, bool) {
|
||||
l, ok := o[key].([]any)
|
||||
return l, ok
|
||||
}
|
||||
|
||||
// lookup returns the first non-nil value among keys.
|
||||
func (o object) lookup(keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if value, ok := o[k]; ok && value != nil {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (o object) first(keys ...string) any {
|
||||
value, _ := o.lookup(keys...)
|
||||
return value
|
||||
}
|
||||
|
||||
func firstItem(list []any) any {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
return list[0]
|
||||
}
|
||||
|
||||
// lastMessage returns nil for an empty slice.
|
||||
func lastMessage(messages []aiobservabilitytypes.Message) *aiobservabilitytypes.Message {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &messages[len(messages)-1]
|
||||
}
|
||||
|
||||
func lastItem(list []any) any {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
return list[len(list)-1]
|
||||
}
|
||||
|
||||
func boolOf(value any) bool {
|
||||
b, _ := value.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// stringOf renders nil as "" and non-strings as compact JSON.
|
||||
func stringOf(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package genaimessages
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestObjectAccessors_NilObject_ZeroValues(t *testing.T) {
|
||||
var o object
|
||||
assert.Nil(t, o.at("key"))
|
||||
assert.False(t, o.has("key"))
|
||||
assert.Equal(t, "", o.str("key"))
|
||||
assert.False(t, o.flag("key"))
|
||||
assert.Nil(t, o.first("a", "b"))
|
||||
_, ok := o.obj("key")
|
||||
assert.False(t, ok)
|
||||
_, ok = o.list("key")
|
||||
assert.False(t, ok)
|
||||
_, ok = o.text("key")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestObjectAccessors_WrongType_ZeroValues(t *testing.T) {
|
||||
o := object{"number": 1.5, "text": "hi"}
|
||||
_, ok := o.obj("number")
|
||||
assert.False(t, ok)
|
||||
_, ok = o.list("text")
|
||||
assert.False(t, ok)
|
||||
_, ok = o.text("number")
|
||||
assert.False(t, ok)
|
||||
assert.False(t, o.flag("text"))
|
||||
assert.Equal(t, "1.5", o.str("number"))
|
||||
}
|
||||
|
||||
func TestSliceHelpers_EmptySlice_NilOrZero(t *testing.T) {
|
||||
assert.Nil(t, firstItem(nil))
|
||||
assert.Nil(t, lastItem([]any{}))
|
||||
assert.Nil(t, lastMessage(nil))
|
||||
assert.Equal(t, "b", lastItem([]any{"a", "b"}))
|
||||
messages := []aiobservabilitytypes.Message{{Role: aiobservabilitytypes.MessageRoleUser}, {Role: aiobservabilitytypes.MessageRoleAssistant}}
|
||||
assert.Same(t, &messages[1], lastMessage(messages))
|
||||
}
|
||||
|
||||
func TestToObject_AcceptsDecodedMapAndObject(t *testing.T) {
|
||||
_, ok := toObject(map[string]any{"a": 1})
|
||||
assert.True(t, ok)
|
||||
_, ok = toObject(object{"a": 1})
|
||||
assert.True(t, ok)
|
||||
_, ok = toObject([]any{})
|
||||
assert.False(t, ok)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ const (
|
||||
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
|
||||
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
|
||||
GenAIUsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens"
|
||||
GenAIUsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens"
|
||||
|
||||
GenAIInputMessages = "gen_ai.input.messages"
|
||||
GenAIOutputMessages = "gen_ai.output.messages"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
const (
|
||||
MessageRoleSystem MessageRole = "system"
|
||||
MessageRoleUser MessageRole = "user"
|
||||
MessageRoleAssistant MessageRole = "assistant"
|
||||
MessageRoleTool MessageRole = "tool"
|
||||
)
|
||||
|
||||
const (
|
||||
FinishReasonStop FinishReason = "stop"
|
||||
FinishReasonToolCall FinishReason = "tool_call"
|
||||
FinishReasonLength FinishReason = "length"
|
||||
FinishReasonContentFilter FinishReason = "content_filter"
|
||||
FinishReasonError FinishReason = "error"
|
||||
)
|
||||
|
||||
const (
|
||||
PartTypeText PartType = "text"
|
||||
PartTypeThinking PartType = "thinking"
|
||||
PartTypeToolCall PartType = "tool_call"
|
||||
PartTypeToolResult PartType = "tool_result"
|
||||
PartTypeGeneric PartType = "generic"
|
||||
)
|
||||
|
||||
type MessageRole string
|
||||
|
||||
type FinishReason string
|
||||
|
||||
type PartType string
|
||||
|
||||
// Part is one piece of a message. Which fields are set depends on Type:
|
||||
//
|
||||
// text Content
|
||||
// thinking Content, empty when the provider hid the reasoning
|
||||
// tool_call ID, Name, Arguments
|
||||
// tool_result ToolCallID, Name, Content, IsError
|
||||
// generic Content (the original value, always a string)
|
||||
type Part struct {
|
||||
Type PartType `json:"type" required:"true"`
|
||||
Content string `json:"content,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments any `json:"arguments,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role MessageRole `json:"role,omitempty"`
|
||||
Content []Part `json:"content" required:"true" nullable:"false"`
|
||||
FinishReason FinishReason `json:"finishReason,omitempty"`
|
||||
}
|
||||
|
||||
func (PartType) Enum() []any {
|
||||
return []any{PartTypeText, PartTypeThinking, PartTypeToolCall, PartTypeToolResult, PartTypeGeneric}
|
||||
}
|
||||
@@ -31,15 +31,12 @@ type SpanMapperStore interface {
|
||||
|
||||
// TraceStore defines the data access interface for trace detail queries.
|
||||
type TraceStore interface {
|
||||
GetTraceBounds(ctx context.Context, traceID string) (*TraceBounds, error)
|
||||
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) (*TraceStats, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
|
||||
GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds, page ThreadPage) ([]StorableSpan, error)
|
||||
GetThreadCursor(ctx context.Context, traceID string, bounds *TraceBounds, spanID string) (*ThreadCursor, error)
|
||||
|
||||
GetSpanCountByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanDurationByField(ctx context.Context, traceID string, bounds *TraceBounds, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanCountByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
GetSpanDurationByField(ctx context.Context, traceID string, summary *TraceSummary, fieldKey telemetrytypes.TelemetryFieldKey) (map[string]uint64, error)
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes/genaimessages"
|
||||
)
|
||||
|
||||
const (
|
||||
threadDefaultLimit = 20
|
||||
threadMaxLimit = 100
|
||||
)
|
||||
|
||||
const (
|
||||
ThreadAfter ThreadFrom = iota // > cursor, ascending
|
||||
ThreadAt // >= cursor, ascending
|
||||
ThreadBefore // < cursor, descending
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeThreadInvalidLimit = errors.MustNewCode("trace_thread_invalid_limit")
|
||||
ErrCodeThreadInvalidCursor = errors.MustNewCode("trace_thread_invalid_cursor")
|
||||
ErrCodeThreadInvalidAnchor = errors.MustNewCode("trace_thread_invalid_anchor")
|
||||
ErrCodeThreadSpanNotFound = errors.MustNewCode("trace_thread_span_not_found")
|
||||
)
|
||||
|
||||
type GetTraceThreadParams struct {
|
||||
Limit int `query:"limit" description:"Page size, at most 100. 0 means 20."`
|
||||
After string `query:"after" description:"The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId."`
|
||||
Before string `query:"before" description:"The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId."`
|
||||
SpanID string `query:"spanId" description:"Returns the page around this span. Set only one of after, before and spanId."`
|
||||
}
|
||||
|
||||
type ThreadQuery struct {
|
||||
Limit int
|
||||
After *ThreadCursor
|
||||
Before *ThreadCursor
|
||||
SpanID string
|
||||
}
|
||||
|
||||
// ThreadCursor is the (TimeUnixNano, SpanID) key of a span.
|
||||
type ThreadCursor struct {
|
||||
TimeUnixNano uint64 `json:"timeUnixNano"`
|
||||
SpanID string `json:"spanId"`
|
||||
}
|
||||
|
||||
type ThreadFrom int
|
||||
|
||||
type ThreadPage struct {
|
||||
Cursor *ThreadCursor
|
||||
From ThreadFrom
|
||||
Limit int
|
||||
}
|
||||
|
||||
type GettableTraceThread struct {
|
||||
Spans []*ThreadSpan `json:"spans" required:"true" nullable:"false"`
|
||||
PrevCursor string `json:"prevCursor,omitempty"`
|
||||
NextCursor string `json:"nextCursor,omitempty"`
|
||||
}
|
||||
|
||||
// ThreadSpan carries the fields the span details pane reads; snake_case keys match WaterfallSpan.
|
||||
// The formatted fields are set only when the span has the matching gen_ai messages attribute.
|
||||
type ThreadSpan struct {
|
||||
SpanID string `json:"span_id" required:"true"`
|
||||
TraceID string `json:"trace_id" required:"true"`
|
||||
ParentSpanID string `json:"parent_span_id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
KindString string `json:"kind_string" required:"true"`
|
||||
TimeUnix uint64 `json:"time_unix" required:"true"`
|
||||
DurationNano uint64 `json:"duration_nano" required:"true"`
|
||||
HasError bool `json:"has_error" required:"true"`
|
||||
StatusCodeString string `json:"status_code_string" required:"true"`
|
||||
StatusMessage string `json:"status_message" required:"true"`
|
||||
Resource map[string]string `json:"resource" required:"true" nullable:"false"`
|
||||
Attributes map[string]any `json:"attributes" required:"true" nullable:"false"`
|
||||
Events []Event `json:"events" required:"true" nullable:"false"`
|
||||
References []OtelSpanRef `json:"references" required:"true" nullable:"false"`
|
||||
FormattedInput []aiobservabilitytypes.Message `json:"formatted_input,omitempty" nullable:"false"`
|
||||
FormattedOutput []aiobservabilitytypes.Message `json:"formatted_output,omitempty" nullable:"false"`
|
||||
|
||||
timeUnixNano uint64
|
||||
}
|
||||
|
||||
func NewThreadQuery(params *GetTraceThreadParams) (*ThreadQuery, error) {
|
||||
query := &ThreadQuery{Limit: params.Limit, SpanID: params.SpanID}
|
||||
if query.Limit < 0 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot be negative, got %d", query.Limit)
|
||||
}
|
||||
if query.Limit == 0 {
|
||||
query.Limit = threadDefaultLimit
|
||||
}
|
||||
if query.Limit > threadMaxLimit {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidLimit, "limit cannot exceed %d, got %d", threadMaxLimit, query.Limit)
|
||||
}
|
||||
|
||||
anchors := 0
|
||||
for _, value := range []string{params.After, params.Before, params.SpanID} {
|
||||
if value != "" {
|
||||
anchors++
|
||||
}
|
||||
}
|
||||
if anchors > 1 {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidAnchor, "only one of after, before and spanId can be set")
|
||||
}
|
||||
|
||||
encoded := params.After
|
||||
if encoded == "" {
|
||||
encoded = params.Before
|
||||
}
|
||||
if encoded == "" {
|
||||
return query, nil
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
|
||||
}
|
||||
cursor := new(ThreadCursor)
|
||||
if err := json.Unmarshal(data, cursor); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeThreadInvalidCursor, "invalid cursor")
|
||||
}
|
||||
if cursor.SpanID == "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeThreadInvalidCursor, "invalid cursor: missing span id")
|
||||
}
|
||||
if params.After != "" {
|
||||
query.After = cursor
|
||||
} else {
|
||||
query.Before = cursor
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func (c ThreadCursor) Encode() string {
|
||||
data, _ := json.Marshal(c)
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// NewGettableTraceThread takes up to limit+1 spans on each side of the anchor: before in
|
||||
// descending order, after in ascending order. Half the page goes to before, the rest to after,
|
||||
// and a short side gives its room to the other. An extra span on a side sets that side's cursor.
|
||||
func NewGettableTraceThread(traceID string, query *ThreadQuery, before, after []StorableSpan) *GettableTraceThread {
|
||||
nAfter := min(len(after), query.Limit-min(len(before), query.Limit/2))
|
||||
nBefore := min(len(before), query.Limit-nAfter)
|
||||
hasPrev := len(before) > nBefore || query.After != nil
|
||||
hasNext := len(after) > nAfter || query.Before != nil
|
||||
|
||||
spans := make([]*ThreadSpan, 0, nBefore+nAfter)
|
||||
for i := nBefore - 1; i >= 0; i-- {
|
||||
spans = append(spans, newThreadSpan(traceID, &before[i]))
|
||||
}
|
||||
for i := range nAfter {
|
||||
spans = append(spans, newThreadSpan(traceID, &after[i]))
|
||||
}
|
||||
|
||||
thread := &GettableTraceThread{Spans: spans}
|
||||
if len(spans) == 0 {
|
||||
return thread
|
||||
}
|
||||
if hasPrev {
|
||||
thread.PrevCursor = spans[0].cursor().Encode()
|
||||
}
|
||||
if hasNext {
|
||||
thread.NextCursor = spans[len(spans)-1].cursor().Encode()
|
||||
}
|
||||
return thread
|
||||
}
|
||||
|
||||
func (s *ThreadSpan) cursor() ThreadCursor {
|
||||
return ThreadCursor{TimeUnixNano: s.timeUnixNano, SpanID: s.SpanID}
|
||||
}
|
||||
|
||||
func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
|
||||
resources := make(map[string]string, len(storable.ResourcesString))
|
||||
maps.Copy(resources, storable.ResourcesString)
|
||||
timeUnixNano := uint64(storable.StartTime.UnixNano())
|
||||
attributes := threadAttributes(storable)
|
||||
span := &ThreadSpan{
|
||||
SpanID: storable.SpanID,
|
||||
TraceID: traceID,
|
||||
ParentSpanID: storable.ParentSpanID,
|
||||
Name: storable.Name,
|
||||
KindString: storable.SpanKind,
|
||||
TimeUnix: timeUnixNano / 1_000_000, // client expects millis, as in the waterfall
|
||||
DurationNano: storable.DurationNano,
|
||||
HasError: storable.HasError,
|
||||
StatusCodeString: storable.StatusCodeString,
|
||||
StatusMessage: storable.StatusMessage,
|
||||
Resource: resources,
|
||||
Attributes: attributes,
|
||||
Events: storable.UnmarshalledEvents(),
|
||||
References: storable.UnmarshalledRefs(),
|
||||
timeUnixNano: timeUnixNano,
|
||||
}
|
||||
if v, ok := attributes[aiobservabilitytypes.GenAIInputMessages]; ok {
|
||||
span.FormattedInput = genaimessages.Normalize(v)
|
||||
}
|
||||
if v, ok := attributes[aiobservabilitytypes.GenAIOutputMessages]; ok {
|
||||
span.FormattedOutput = genaimessages.Normalize(v)
|
||||
}
|
||||
return span
|
||||
}
|
||||
|
||||
// threadAttributes reads the JSON column and falls back to the legacy maps for spans written
|
||||
// before the JSON rollout.
|
||||
func threadAttributes(storable *StorableSpan) map[string]any {
|
||||
if len(storable.AttributesJSON) > 0 {
|
||||
attributes := make(map[string]any, len(storable.AttributesJSON))
|
||||
storable.AttributesJSON.FlattenInto("", attributes)
|
||||
return attributes
|
||||
}
|
||||
return storable.Attributes()
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewThreadQuery_Cursor(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
cursor string
|
||||
want *ThreadCursor
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "EncodedCursor_RoundTrips", cursor: ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}.Encode(), want: &ThreadCursor{TimeUnixNano: 1757500000123456789, SpanID: "f1fa1bc863e94dd0"}},
|
||||
{name: "NotBase64_Rejected", cursor: "not base64!", wantErr: true},
|
||||
{name: "NotJSON_Rejected", cursor: "bm90IGpzb24", wantErr: true},
|
||||
{name: "MissingSpanID_Rejected", cursor: "eyJ0aW1lVW5peE5hbm8iOiAxfQ", wantErr: true},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
after, err := NewThreadQuery(&GetTraceThreadParams{After: testCase.cursor})
|
||||
before, errBefore := NewThreadQuery(&GetTraceThreadParams{Before: testCase.cursor})
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Error(t, errBefore)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, errBefore)
|
||||
assert.Equal(t, testCase.want, after.After)
|
||||
assert.Nil(t, after.Before)
|
||||
assert.Equal(t, testCase.want, before.Before)
|
||||
assert.Nil(t, before.After)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadAttributes(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
span StorableSpan
|
||||
wantAttrs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "JSONColumnPresent_LegacyMapsIgnored",
|
||||
span: StorableSpan{
|
||||
AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"request": map[string]any{"model": "json"}}},
|
||||
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
|
||||
},
|
||||
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
|
||||
},
|
||||
{
|
||||
name: "NoJSONColumn_FallsBackToLegacyMaps",
|
||||
span: StorableSpan{
|
||||
AttributesString: map[string]string{"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`},
|
||||
AttributesNumber: map[string]float64{"gen_ai.usage.input_tokens": 12},
|
||||
AttributesBool: map[string]bool{"gen_ai.stream": true},
|
||||
},
|
||||
wantAttrs: map[string]any{
|
||||
"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`,
|
||||
"gen_ai.usage.input_tokens": float64(12),
|
||||
"gen_ai.stream": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.wantAttrs, threadAttributes(&testCase.span))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewThreadSpan(t *testing.T) {
|
||||
userHi := []aiobservabilitytypes.Message{{
|
||||
Role: aiobservabilitytypes.MessageRoleUser,
|
||||
Content: []aiobservabilitytypes.Part{{Type: aiobservabilitytypes.PartTypeText, Content: "hi"}},
|
||||
}}
|
||||
assistantHello := []aiobservabilitytypes.Message{{
|
||||
Role: aiobservabilitytypes.MessageRoleAssistant,
|
||||
Content: []aiobservabilitytypes.Part{{Type: aiobservabilitytypes.PartTypeText, Content: "hello"}},
|
||||
FinishReason: aiobservabilitytypes.FinishReasonStop,
|
||||
}}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
span StorableSpan
|
||||
wantInput []aiobservabilitytypes.Message
|
||||
wantOutput []aiobservabilitytypes.Message
|
||||
}{
|
||||
{
|
||||
name: "InputAndOutput_BothFormatted",
|
||||
span: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{
|
||||
"input": map[string]any{"messages": `[{"role":"user","parts":[{"type":"text","content":"hi"}]}]`},
|
||||
"output": map[string]any{"messages": `[{"role":"assistant","parts":[{"type":"text","content":"hello"}],"finish_reason":"stop"}]`},
|
||||
}}},
|
||||
wantInput: userHi,
|
||||
wantOutput: assistantHello,
|
||||
},
|
||||
{
|
||||
name: "InputOnly_OutputUnset",
|
||||
span: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"gen_ai": map[string]any{"input": map[string]any{"messages": `[{"role":"user","content":"hi"}]`}}}},
|
||||
wantInput: userHi,
|
||||
},
|
||||
{
|
||||
name: "NoMessages_BothUnset",
|
||||
span: StorableSpan{AttributesJSON: telemetrystoretypes.JSONValue{"http": map[string]any{"method": "GET"}}},
|
||||
},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
span := newThreadSpan("trace-1", &testCase.span)
|
||||
assert.Equal(t, testCase.wantInput, span.FormattedInput)
|
||||
assert.Equal(t, testCase.wantOutput, span.FormattedOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package spantypes
|
||||
|
||||
// TraceStats is the single-row result of the trace summary aggregate query.
|
||||
type TraceStats struct {
|
||||
StartNs uint64
|
||||
EndNs uint64
|
||||
RootServiceName string
|
||||
RootEntryPoint string
|
||||
RootSpanStatusCode string
|
||||
TotalSpans uint64
|
||||
TotalErrorSpans uint64
|
||||
HasMissingSpans bool
|
||||
GenAISpanCount uint64
|
||||
Tokens TraceAITokens
|
||||
TotalCost *float64
|
||||
}
|
||||
|
||||
// GettableTraceSummary is the response for the trace summary API; the trace-level
|
||||
// fields match the waterfall response.
|
||||
type GettableTraceSummary struct {
|
||||
StartTimestampMillis uint64 `json:"startTimestampMillis" required:"true"`
|
||||
EndTimestampMillis uint64 `json:"endTimestampMillis" required:"true"`
|
||||
RootServiceName string `json:"rootServiceName" required:"true"`
|
||||
RootServiceEntryPoint string `json:"rootServiceEntryPoint" required:"true"`
|
||||
RootSpanStatusCode string `json:"rootSpanStatusCode" required:"true"`
|
||||
TotalSpansCount uint64 `json:"totalSpansCount" required:"true"`
|
||||
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount" required:"true"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans" required:"true"`
|
||||
AI *TraceAISummary `json:"ai,omitempty"`
|
||||
}
|
||||
|
||||
// TraceAISummary is present when any span carries a gen_ai gate key.
|
||||
type TraceAISummary struct {
|
||||
Tokens TraceAITokens `json:"tokens" required:"true"`
|
||||
// TotalCost is omitted when no span carries a cost attribute.
|
||||
TotalCost *float64 `json:"totalCost,omitempty" nullable:"false"`
|
||||
}
|
||||
|
||||
type TraceAITokens struct {
|
||||
Input uint64 `json:"input" required:"true"`
|
||||
Output uint64 `json:"output" required:"true"`
|
||||
CacheRead uint64 `json:"cacheRead" required:"true"`
|
||||
CacheWrite uint64 `json:"cacheWrite" required:"true"`
|
||||
Reasoning uint64 `json:"reasoning" required:"true"`
|
||||
}
|
||||
|
||||
func NewGettableTraceSummary(stats *TraceStats) *GettableTraceSummary {
|
||||
summary := &GettableTraceSummary{
|
||||
StartTimestampMillis: stats.StartNs / 1_000_000,
|
||||
EndTimestampMillis: stats.EndNs / 1_000_000,
|
||||
RootServiceName: stats.RootServiceName,
|
||||
RootServiceEntryPoint: stats.RootEntryPoint,
|
||||
RootSpanStatusCode: stats.RootSpanStatusCode,
|
||||
TotalSpansCount: stats.TotalSpans,
|
||||
TotalErrorSpansCount: stats.TotalErrorSpans,
|
||||
HasMissingSpans: stats.HasMissingSpans,
|
||||
}
|
||||
if stats.GenAISpanCount > 0 {
|
||||
summary.AI = &TraceAISummary{Tokens: stats.Tokens, TotalCost: stats.TotalCost}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user