mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-12 13:10:41 +01:00
Compare commits
27 Commits
feat/trace
...
feat/ai-tr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e918a90935 | ||
|
|
8f38863b20 | ||
|
|
7b51c47e6d | ||
|
|
b9565c4866 | ||
|
|
1096a9f70c | ||
|
|
b19ba11d98 | ||
|
|
51663240e0 | ||
|
|
143c3a64f1 | ||
|
|
b4da88d5ec | ||
|
|
277124320a | ||
|
|
9a11ebf350 | ||
|
|
a4cb6ca847 | ||
|
|
f50d786155 | ||
|
|
bbd9fd1ad5 | ||
|
|
b0b3409827 | ||
|
|
37932c7e84 | ||
|
|
7313b02455 | ||
|
|
a1a38fc7da | ||
|
|
f76e3e3085 | ||
|
|
5b9d854518 | ||
|
|
0a9920583b | ||
|
|
ae0d8cb1a4 | ||
|
|
abb242b1f9 | ||
|
|
61f6370f45 | ||
|
|
f8b22c0feb | ||
|
|
9e3a6bb35d | ||
|
|
f5f019f61b |
@@ -1,5 +1,219 @@
|
||||
components:
|
||||
schemas:
|
||||
AiobservabilitytypesBlobPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesChatMessage:
|
||||
properties:
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesCompactionPart:
|
||||
properties:
|
||||
content:
|
||||
nullable: true
|
||||
type: string
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
AiobservabilitytypesFilePart:
|
||||
properties:
|
||||
file_id:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- file_id
|
||||
type: object
|
||||
AiobservabilitytypesGenericPart:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCall:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCallResponse:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesInputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesChatMessage'
|
||||
type: array
|
||||
AiobservabilitytypesOutputMessage:
|
||||
properties:
|
||||
finish_reason:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesOutputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessage'
|
||||
type: array
|
||||
AiobservabilitytypesPart:
|
||||
discriminator:
|
||||
mapping:
|
||||
blob: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
compaction: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
file: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
reasoning: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
server_tool_call: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
server_tool_call_response: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
text: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
tool_call: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
tool_call_response: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
uri: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesGenericPart'
|
||||
type: object
|
||||
AiobservabilitytypesParts:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesPart'
|
||||
type: array
|
||||
AiobservabilitytypesReasoningPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallPart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
server_tool_call:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCall'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- server_tool_call
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
server_tool_call_response:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCallResponse'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- server_tool_call_response
|
||||
type: object
|
||||
AiobservabilitytypesTextPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesToolCallRequestPart:
|
||||
properties:
|
||||
arguments: {}
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
type: object
|
||||
AiobservabilitytypesToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
response:
|
||||
nullable: true
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- response
|
||||
type: object
|
||||
AiobservabilitytypesUriPart:
|
||||
properties:
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- uri
|
||||
type: object
|
||||
AlertmanagertypesChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -10225,6 +10439,16 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
formatted_input:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formatted_output:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatter:
|
||||
type: string
|
||||
formatter_warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
@@ -10267,6 +10491,10 @@ components:
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
- formatted_input
|
||||
- formatted_output
|
||||
- formatter
|
||||
- formatter_warnings
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
@@ -16134,9 +16362,12 @@ paths:
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pass nextCursor as after or prevCursor as before to page, or spanId
|
||||
to open the page around a span.
|
||||
description: Returns the spans carrying gen_ai input or output messages, or
|
||||
a tool execution, in timestamp order. Messages already in the OTel GenAI shape
|
||||
are decoded into formatted_input and formatted_output, with formatter naming
|
||||
the converter and formatter_warnings what it could not resolve. 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.
|
||||
|
||||
@@ -4,6 +4,264 @@
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
export enum AiobservabilitytypesBlobPartDTOType {
|
||||
blob = 'blob',
|
||||
}
|
||||
export interface AiobservabilitytypesBlobPartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
mime_type?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modality: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum blob
|
||||
*/
|
||||
type: AiobservabilitytypesBlobPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesTextPartDTOType {
|
||||
text = 'text',
|
||||
}
|
||||
export interface AiobservabilitytypesTextPartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum text
|
||||
*/
|
||||
type: AiobservabilitytypesTextPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesToolCallRequestPartDTOType {
|
||||
tool_call = 'tool_call',
|
||||
}
|
||||
export interface AiobservabilitytypesToolCallRequestPartDTO {
|
||||
arguments?: unknown;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum tool_call
|
||||
*/
|
||||
type: AiobservabilitytypesToolCallRequestPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesToolCallResponsePartDTOType {
|
||||
tool_call_response = 'tool_call_response',
|
||||
}
|
||||
export type AiobservabilitytypesToolCallResponsePartDTOResponse =
|
||||
| unknown
|
||||
| null;
|
||||
|
||||
export interface AiobservabilitytypesToolCallResponsePartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @nullable true
|
||||
*/
|
||||
response: AiobservabilitytypesToolCallResponsePartDTOResponse;
|
||||
/**
|
||||
* @type string
|
||||
* @enum tool_call_response
|
||||
*/
|
||||
type: AiobservabilitytypesToolCallResponsePartDTOType;
|
||||
}
|
||||
|
||||
export interface AiobservabilitytypesGenericServerToolCallDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesServerToolCallPartDTOType {
|
||||
server_tool_call = 'server_tool_call',
|
||||
}
|
||||
export interface AiobservabilitytypesServerToolCallPartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
server_tool_call: AiobservabilitytypesGenericServerToolCallDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum server_tool_call
|
||||
*/
|
||||
type: AiobservabilitytypesServerToolCallPartDTOType;
|
||||
}
|
||||
|
||||
export interface AiobservabilitytypesGenericServerToolCallResponseDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesServerToolCallResponsePartDTOType {
|
||||
server_tool_call_response = 'server_tool_call_response',
|
||||
}
|
||||
export interface AiobservabilitytypesServerToolCallResponsePartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
server_tool_call_response: AiobservabilitytypesGenericServerToolCallResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @enum server_tool_call_response
|
||||
*/
|
||||
type: AiobservabilitytypesServerToolCallResponsePartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesFilePartDTOType {
|
||||
file = 'file',
|
||||
}
|
||||
export interface AiobservabilitytypesFilePartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
file_id: string;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
mime_type?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modality: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum file
|
||||
*/
|
||||
type: AiobservabilitytypesFilePartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesUriPartDTOType {
|
||||
uri = 'uri',
|
||||
}
|
||||
export interface AiobservabilitytypesUriPartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
mime_type?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modality: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum uri
|
||||
*/
|
||||
type: AiobservabilitytypesUriPartDTOType;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesReasoningPartDTOType {
|
||||
reasoning = 'reasoning',
|
||||
}
|
||||
export interface AiobservabilitytypesReasoningPartDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
content: string;
|
||||
/**
|
||||
* @type string
|
||||
* @enum reasoning
|
||||
*/
|
||||
type: AiobservabilitytypesReasoningPartDTOType;
|
||||
}
|
||||
|
||||
export enum AiobservabilitytypesCompactionPartDTOType {
|
||||
compaction = 'compaction',
|
||||
}
|
||||
export interface AiobservabilitytypesCompactionPartDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
content?: string | null;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
id?: string | null;
|
||||
/**
|
||||
* @type string
|
||||
* @enum compaction
|
||||
*/
|
||||
type: AiobservabilitytypesCompactionPartDTOType;
|
||||
}
|
||||
|
||||
export interface AiobservabilitytypesGenericPartDTO {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type AiobservabilitytypesPartDTO =
|
||||
| AiobservabilitytypesTextPartDTO
|
||||
| AiobservabilitytypesToolCallRequestPartDTO
|
||||
| AiobservabilitytypesToolCallResponsePartDTO
|
||||
| AiobservabilitytypesServerToolCallPartDTO
|
||||
| AiobservabilitytypesServerToolCallResponsePartDTO
|
||||
| AiobservabilitytypesBlobPartDTO
|
||||
| AiobservabilitytypesFilePartDTO
|
||||
| AiobservabilitytypesUriPartDTO
|
||||
| AiobservabilitytypesReasoningPartDTO
|
||||
| AiobservabilitytypesCompactionPartDTO
|
||||
| AiobservabilitytypesGenericPartDTO;
|
||||
|
||||
export type AiobservabilitytypesPartsDTO = AiobservabilitytypesPartDTO[];
|
||||
|
||||
export interface AiobservabilitytypesChatMessageDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
name?: string | null;
|
||||
parts: AiobservabilitytypesPartsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role: string;
|
||||
}
|
||||
|
||||
export type AiobservabilitytypesInputMessagesDTO =
|
||||
AiobservabilitytypesChatMessageDTO[];
|
||||
|
||||
export interface AiobservabilitytypesOutputMessageDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
finish_reason?: string | null;
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
name?: string | null;
|
||||
parts: AiobservabilitytypesPartsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
role: string;
|
||||
}
|
||||
|
||||
export type AiobservabilitytypesOutputMessagesDTO =
|
||||
AiobservabilitytypesOutputMessageDTO[];
|
||||
|
||||
export interface AlertmanagertypesChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -11452,6 +11710,16 @@ export interface SpantypesThreadSpanDTO {
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
formatted_input: AiobservabilitytypesInputMessagesDTO;
|
||||
formatted_output: AiobservabilitytypesOutputMessagesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
formatter: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
formatter_warnings: string[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
|
||||
@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. 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 = (
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.parts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.textBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
line-height: var(--line-height-20);
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-word;
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
:global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(ol),
|
||||
:global(ul) {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
padding-left: var(--spacing-10);
|
||||
}
|
||||
}
|
||||
|
||||
.plainText {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex-shrink: 0;
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
border-radius: var(--radius-round);
|
||||
|
||||
&[data-tone='success'] {
|
||||
background: var(--accent-forest);
|
||||
}
|
||||
|
||||
&[data-tone='warning'] {
|
||||
background: var(--accent-amber);
|
||||
}
|
||||
|
||||
&[data-tone='error'] {
|
||||
background: var(--accent-cherry);
|
||||
}
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chipSeparator {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chipValue {
|
||||
display: inline-flex;
|
||||
gap: var(--spacing-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.toolCall,
|
||||
.toolResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolCall {
|
||||
border-radius: var(--radius-2);
|
||||
transition: outline-color 0.3s ease;
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: var(--spacing-1);
|
||||
|
||||
&.isHighlighted {
|
||||
outline-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
|
||||
&.boxed {
|
||||
gap: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
|
||||
.collapsibleToggle {
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-3) var(--spacing-5);
|
||||
}
|
||||
|
||||
.collapsibleBody {
|
||||
padding: 0 var(--spacing-5) var(--spacing-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapsibleToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.collapsibleLabel {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.collapsibleBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.json {
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
cursor: auto;
|
||||
|
||||
:global(.pretty-view) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mediaChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
align-self: flex-start;
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-round);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import { ThreadMessage } from '../TraceDetailsThread/types';
|
||||
import CollapsibleBlock from './CollapsibleBlock';
|
||||
import MessagePart from './MessagePart';
|
||||
import PartChip from './PartChip';
|
||||
import { getFinishReasonTone, getRoleLabel } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
export interface AIThreadMessageProps {
|
||||
message: ThreadMessage;
|
||||
isOutput?: boolean;
|
||||
isToolLinkEnabled?: (toolCallId: string) => boolean;
|
||||
onToolLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function AIThreadMessage({
|
||||
message,
|
||||
isOutput,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: AIThreadMessageProps): JSX.Element {
|
||||
const { role, content, finishReason } = message;
|
||||
const roleLabel = getRoleLabel(role);
|
||||
const tone = getFinishReasonTone(finishReason);
|
||||
|
||||
const parts = (
|
||||
<div className={styles.parts}>
|
||||
{content.length === 0 ? (
|
||||
<span className={styles.empty}>No content</span>
|
||||
) : (
|
||||
content.map((part, index) => (
|
||||
<MessagePart
|
||||
// Parts have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
part={part}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.message}
|
||||
data-testid={`ai-thread-message-${role || 'unknown'}`}
|
||||
>
|
||||
{role === 'system' ? (
|
||||
<CollapsibleBlock label={roleLabel} testId="ai-message-system-toggle">
|
||||
{parts}
|
||||
</CollapsibleBlock>
|
||||
) : (
|
||||
<>
|
||||
{roleLabel && (
|
||||
<div className={styles.roleHeader}>
|
||||
{roleLabel}
|
||||
{isOutput && (
|
||||
<Badge color={tone} testId="ai-message-output-badge">
|
||||
Output
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{parts}
|
||||
</>
|
||||
)}
|
||||
{finishReason && (
|
||||
<PartChip
|
||||
label="Finish Reason"
|
||||
value={finishReason}
|
||||
tone={tone}
|
||||
testId="ai-message-finish-reason"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AIThreadMessage.defaultProps = {
|
||||
isOutput: false,
|
||||
isToolLinkEnabled: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default AIThreadMessage;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { MouseEvent, ReactNode, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface CollapsibleBlockProps {
|
||||
label: ReactNode;
|
||||
// Boxed: full-width bordered row with the chevron on the right.
|
||||
isBoxed?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function CollapsibleBlock({
|
||||
label,
|
||||
isBoxed = false,
|
||||
testId,
|
||||
children,
|
||||
}: CollapsibleBlockProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(styles.collapsible, isBoxed && styles.boxed)}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapsibleToggle}
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className={styles.collapsibleLabel}>{label}</span>
|
||||
{isOpen ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
{isOpen && <div className={styles.collapsibleBody}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CollapsibleBlock.defaultProps = {
|
||||
isBoxed: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default CollapsibleBlock;
|
||||
@@ -1,56 +0,0 @@
|
||||
import { MouseEvent, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
|
||||
|
||||
import { MAX_TEXT_LENGTH } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface ExpandableTextProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
function ExpandableText({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: ExpandableTextProps): JSX.Element {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isTruncated = !isExpanded && text.length > MAX_TEXT_LENGTH;
|
||||
const visibleText = isTruncated ? `${text.slice(0, MAX_TEXT_LENGTH)}…` : text;
|
||||
|
||||
const handleToggle = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMarkdown ? (
|
||||
<MarkdownRenderer
|
||||
className={styles.text}
|
||||
markdownContent={visibleText}
|
||||
variables={{}}
|
||||
/>
|
||||
) : (
|
||||
<div className={cx(styles.text, styles.plainText)}>{visibleText}</div>
|
||||
)}
|
||||
{text.length > MAX_TEXT_LENGTH && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showMore}
|
||||
onClick={handleToggle}
|
||||
data-testid="ai-message-show-more"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ExpandableText.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default ExpandableText;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { PrettyView } from 'periscope/components/PrettyView';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface JsonBlockProps {
|
||||
data: object;
|
||||
}
|
||||
|
||||
// Expanding tree nodes must not count as a click on the message.
|
||||
function JsonBlock({ data }: JsonBlockProps): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={styles.json}
|
||||
onClick={(event): void => event.stopPropagation()}
|
||||
>
|
||||
<PrettyView
|
||||
data={data as Record<string, unknown>}
|
||||
searchable={false}
|
||||
drawerKey="ai-thread-message"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default JsonBlock;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { MessagePart as MessagePartType } from '../TraceDetailsThread/types';
|
||||
import MediaPart from './parts/MediaPart';
|
||||
import ThinkingPart from './parts/ThinkingPart';
|
||||
import ToolCallPart from './parts/ToolCallPart';
|
||||
import ToolResultPart from './parts/ToolResultPart';
|
||||
import TextContent from './TextContent';
|
||||
|
||||
interface MessagePartProps {
|
||||
part: MessagePartType;
|
||||
isToolLinkEnabled?: (toolCallId: string) => boolean;
|
||||
onToolLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function MessagePart({
|
||||
part,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: MessagePartProps): JSX.Element | null {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <TextContent text={part.content} isMarkdown />;
|
||||
case 'thinking':
|
||||
return <ThinkingPart content={part.content} />;
|
||||
case 'tool_call':
|
||||
return (
|
||||
<ToolCallPart
|
||||
id={part.id}
|
||||
name={part.name}
|
||||
args={part.arguments}
|
||||
server={part.server}
|
||||
/>
|
||||
);
|
||||
case 'tool_result':
|
||||
return (
|
||||
<ToolResultPart
|
||||
toolCallId={part.toolCallId}
|
||||
name={part.name}
|
||||
content={part.content}
|
||||
isError={part.isError}
|
||||
isLinkEnabled={!!isToolLinkEnabled?.(part.toolCallId)}
|
||||
onLinkClick={onToolLinkClick}
|
||||
/>
|
||||
);
|
||||
case 'media':
|
||||
return (
|
||||
<MediaPart
|
||||
modality={part.modality}
|
||||
mimeType={part.mimeType}
|
||||
name={part.name}
|
||||
/>
|
||||
);
|
||||
case 'generic':
|
||||
return <TextContent text={part.content} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
MessagePart.defaultProps = {
|
||||
isToolLinkEnabled: undefined,
|
||||
onToolLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default MessagePart;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { MessageTone } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface PartChipProps {
|
||||
label: string;
|
||||
value?: ReactNode;
|
||||
tone?: MessageTone;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function PartChip({
|
||||
label,
|
||||
value,
|
||||
tone = 'success',
|
||||
testId,
|
||||
children,
|
||||
}: PartChipProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.chip} data-testid={testId}>
|
||||
<span className={styles.dot} data-tone={tone} />
|
||||
<span className={styles.chipLabel}>{label}</span>
|
||||
{value !== undefined && (
|
||||
<>
|
||||
<span className={styles.chipSeparator}>—</span>
|
||||
<span className={styles.chipValue}>{value}</span>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
PartChip.defaultProps = {
|
||||
value: undefined,
|
||||
tone: 'success',
|
||||
testId: undefined,
|
||||
children: undefined,
|
||||
};
|
||||
|
||||
export default PartChip;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import ExpandableText from './ExpandableText';
|
||||
import JsonBlock from './JsonBlock';
|
||||
import { parseJsonObject } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
interface TextContentProps {
|
||||
text: string;
|
||||
isMarkdown?: boolean;
|
||||
}
|
||||
|
||||
/** Renders JSON text as a tree, anything else as (markdown) text. */
|
||||
function TextContent({
|
||||
text,
|
||||
isMarkdown = false,
|
||||
}: TextContentProps): JSX.Element {
|
||||
const json = useMemo(() => parseJsonObject(text), [text]);
|
||||
|
||||
if (json) {
|
||||
return <JsonBlock data={json} />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.textBox}>
|
||||
<ExpandableText text={text} isMarkdown={isMarkdown} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TextContent.defaultProps = {
|
||||
isMarkdown: false,
|
||||
};
|
||||
|
||||
export default TextContent;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Image } from '@signozhq/icons';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface MediaPartProps {
|
||||
modality?: string;
|
||||
mimeType?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function MediaPart({ modality, mimeType, name }: MediaPartProps): JSX.Element {
|
||||
return (
|
||||
<span className={styles.mediaChip} data-testid="ai-media-part">
|
||||
<Image size={12} />
|
||||
<span className="translate-safe">
|
||||
{name || mimeType || modality || 'Media'}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
MediaPart.defaultProps = {
|
||||
modality: undefined,
|
||||
mimeType: undefined,
|
||||
name: undefined,
|
||||
};
|
||||
|
||||
export default MediaPart;
|
||||
@@ -1,30 +0,0 @@
|
||||
import CollapsibleBlock from '../CollapsibleBlock';
|
||||
import ExpandableText from '../ExpandableText';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ThinkingPartProps {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
function ThinkingPart({ content }: ThinkingPartProps): JSX.Element {
|
||||
return (
|
||||
<CollapsibleBlock
|
||||
label="Thinking"
|
||||
isBoxed
|
||||
testId="ai-message-thinking-toggle"
|
||||
>
|
||||
{content ? (
|
||||
<ExpandableText text={content} isMarkdown />
|
||||
) : (
|
||||
<span className={styles.empty}>Reasoning content not captured</span>
|
||||
)}
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
|
||||
ThinkingPart.defaultProps = {
|
||||
content: undefined,
|
||||
};
|
||||
|
||||
export default ThinkingPart;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
import JsonBlock from '../JsonBlock';
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { parseJsonObject, TOOL_CALL_ID_ATTR } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolCallPartProps {
|
||||
id: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
server?: boolean;
|
||||
}
|
||||
|
||||
function ToolCallPart({
|
||||
id,
|
||||
name,
|
||||
args,
|
||||
server,
|
||||
}: ToolCallPartProps): JSX.Element {
|
||||
// Streamed calls can carry truncated, unparseable argument strings.
|
||||
const json = useMemo(() => {
|
||||
if (typeof args === 'string') {
|
||||
return parseJsonObject(args);
|
||||
}
|
||||
return typeof args === 'object' && args !== null ? args : undefined;
|
||||
}, [args]);
|
||||
|
||||
const toolCallAttr = { [TOOL_CALL_ID_ATTR]: id };
|
||||
|
||||
return (
|
||||
<div className={styles.toolCall} {...toolCallAttr} data-testid="ai-tool-call">
|
||||
<PartChip
|
||||
label="tool_call"
|
||||
value={
|
||||
<>
|
||||
<span className={styles.chipKey}>name :</span>
|
||||
<span className={styles.toolName}>{name}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{server && (
|
||||
<Badge color="secondary" variant="outline">
|
||||
server
|
||||
</Badge>
|
||||
)}
|
||||
</PartChip>
|
||||
{json ? (
|
||||
<JsonBlock data={json} />
|
||||
) : (
|
||||
args !== undefined &&
|
||||
args !== null && (
|
||||
<TextContent
|
||||
text={typeof args === 'string' ? args : JSON.stringify(args)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolCallPart.defaultProps = {
|
||||
server: false,
|
||||
};
|
||||
|
||||
export default ToolCallPart;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { MouseEvent, useMemo } from 'react';
|
||||
import { CornerUpRight } from '@signozhq/icons';
|
||||
|
||||
import PartChip from '../PartChip';
|
||||
import TextContent from '../TextContent';
|
||||
import { MAX_INLINE_RESULT_LENGTH, parseJsonObject } from '../utils';
|
||||
|
||||
import styles from '../AIThreadMessage.module.scss';
|
||||
|
||||
interface ToolResultPartProps {
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
isLinkEnabled: boolean;
|
||||
onLinkClick?: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function ToolResultPart({
|
||||
toolCallId,
|
||||
name,
|
||||
content,
|
||||
isError,
|
||||
isLinkEnabled,
|
||||
onLinkClick,
|
||||
}: ToolResultPartProps): JSX.Element {
|
||||
// Short plain results fit in the chip; JSON or long ones render below it.
|
||||
const isInline = useMemo(
|
||||
() => content.length <= MAX_INLINE_RESULT_LENGTH && !parseJsonObject(content),
|
||||
[content],
|
||||
);
|
||||
|
||||
const handleLinkClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
onLinkClick?.(toolCallId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.toolResult} data-testid="ai-tool-result">
|
||||
<PartChip
|
||||
label="tool_result"
|
||||
value={isInline ? content || '…' : name || toolCallId}
|
||||
tone={isError ? 'error' : 'success'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toolLink}
|
||||
disabled={!isLinkEnabled}
|
||||
onClick={handleLinkClick}
|
||||
title={
|
||||
isLinkEnabled ? 'Go to tool call' : 'Matching tool call is not loaded'
|
||||
}
|
||||
data-testid="ai-tool-result-link"
|
||||
>
|
||||
<CornerUpRight size={12} />
|
||||
</button>
|
||||
</PartChip>
|
||||
{!isInline && <TextContent text={content} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ToolResultPart.defaultProps = {
|
||||
name: undefined,
|
||||
isError: false,
|
||||
onLinkClick: undefined,
|
||||
};
|
||||
|
||||
export default ToolResultPart;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { RefObject, useCallback } from 'react';
|
||||
|
||||
import { TOOL_CALL_ID_ATTR } from './utils';
|
||||
|
||||
import styles from './AIThreadMessage.module.scss';
|
||||
|
||||
const HIGHLIGHT_MS = 1500;
|
||||
|
||||
interface ToolCallLinks {
|
||||
isToolLinkEnabled: (toolCallId: string) => boolean;
|
||||
onToolLinkClick: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
/** Links tool results to their calls in `rootRef`; enabled only for ids in `toolCallIds`. */
|
||||
export function useToolCallLinks(
|
||||
rootRef: RefObject<HTMLElement>,
|
||||
toolCallIds: Set<string>,
|
||||
): ToolCallLinks {
|
||||
const isToolLinkEnabled = useCallback(
|
||||
(toolCallId: string): boolean => toolCallIds.has(toolCallId),
|
||||
[toolCallIds],
|
||||
);
|
||||
|
||||
const onToolLinkClick = useCallback(
|
||||
(toolCallId: string): void => {
|
||||
const target = rootRef.current?.querySelector<HTMLElement>(
|
||||
`[${TOOL_CALL_ID_ATTR}="${CSS.escape(toolCallId)}"]`,
|
||||
);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add(styles.isHighlighted);
|
||||
window.setTimeout(
|
||||
() => target.classList.remove(styles.isHighlighted),
|
||||
HIGHLIGHT_MS,
|
||||
);
|
||||
},
|
||||
[rootRef],
|
||||
);
|
||||
|
||||
return { isToolLinkEnabled, onToolLinkClick };
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ThreadMessage } from '../TraceDetailsThread/types';
|
||||
|
||||
export const MAX_TEXT_LENGTH = 1000;
|
||||
|
||||
export const MAX_INLINE_RESULT_LENGTH = 80;
|
||||
|
||||
export const TOOL_CALL_ID_ATTR = 'data-tool-call-id';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
system: 'System',
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
tool: 'Tool',
|
||||
};
|
||||
|
||||
export type MessageTone = 'success' | 'warning' | 'error';
|
||||
|
||||
const FINISH_REASON_TONES: Record<string, MessageTone> = {
|
||||
length: 'warning',
|
||||
content_filter: 'warning',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/** Undefined for missing or unknown roles, which render headerless. */
|
||||
export function getRoleLabel(role?: string): string | undefined {
|
||||
return role ? ROLE_LABELS[role] : undefined;
|
||||
}
|
||||
|
||||
export function getFinishReasonTone(reason?: string): MessageTone {
|
||||
return (reason && FINISH_REASON_TONES[reason]) || 'success';
|
||||
}
|
||||
|
||||
/** Parses objects and arrays only; scalars and invalid JSON return undefined. */
|
||||
export function parseJsonObject(text: string): object | undefined {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function collectToolCallIds(messages: ThreadMessage[]): string[] {
|
||||
return messages.flatMap((message) =>
|
||||
message.content.flatMap((part) =>
|
||||
part.type === 'tool_call' ? [part.id] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,3 @@
|
||||
cursor: default;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// Above the floating span details panel (z-index 999).
|
||||
.tooltip {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
--tooltip-z-index: 1000;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EntityMetadataItem.module.scss';
|
||||
|
||||
interface EntityMetadataItemProps {
|
||||
tooltip: TooltipSimpleProps['title'];
|
||||
tooltip: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -16,10 +16,7 @@ function EntityMetadataItem({
|
||||
children,
|
||||
}: EntityMetadataItemProps): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={tooltip}
|
||||
tooltipContentProps={{ className: styles.tooltip }}
|
||||
>
|
||||
<TooltipSimple title={tooltip}>
|
||||
<span className={styles.item}>
|
||||
{icon}
|
||||
<Typography.Text as="span">{children}</Typography.Text>
|
||||
|
||||
@@ -8,12 +8,3 @@
|
||||
// Keep Typography.Text at the row's size (it reads this var, not inherited font-size).
|
||||
--typography-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.tokenTooltip {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.tokenTooltipTitle {
|
||||
padding-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { CalendarClock, Coins, Landmark, Server, Timer } from '@signozhq/icons';
|
||||
import { CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import cx from 'classnames';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatCost,
|
||||
formatTokens,
|
||||
getTotalInputTokens,
|
||||
hasValue,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import EntityMetadataItem from './EntityMetadataItem';
|
||||
import TokenUsageTooltip from './TokenUsageTooltip';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
@@ -24,8 +16,6 @@ interface EntityMetadataRowProps {
|
||||
execTimePercent?: number;
|
||||
timestamp?: string;
|
||||
statusCode?: string | number;
|
||||
tokens?: AiTokenCounts;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 14;
|
||||
@@ -42,11 +32,8 @@ function EntityMetadataRow({
|
||||
execTimePercent,
|
||||
timestamp,
|
||||
statusCode,
|
||||
tokens,
|
||||
cost,
|
||||
}: EntityMetadataRowProps): JSX.Element {
|
||||
const entityLabel = entity === 'trace' ? 'Trace' : 'Span';
|
||||
const totalInput = tokens && getTotalInputTokens(tokens);
|
||||
const durationTooltip =
|
||||
entity === 'trace' ? 'Trace Duration' : 'Span Duration';
|
||||
// Single source of duration formatting so both rows label units identically.
|
||||
@@ -104,24 +91,6 @@ function EntityMetadataRow({
|
||||
<HttpStatusBadge statusCode={statusCode} />
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{tokens && (
|
||||
<EntityMetadataItem
|
||||
tooltip={<TokenUsageTooltip tokens={tokens} />}
|
||||
icon={<Coins size={ICON_SIZE} />}
|
||||
>
|
||||
Tokens: {formatTokens(totalInput)} → {formatTokens(tokens.output)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
|
||||
{hasValue(cost) && (
|
||||
<EntityMetadataItem
|
||||
tooltip="Total cost"
|
||||
icon={<Landmark size={ICON_SIZE} />}
|
||||
>
|
||||
Cost — {formatCost(cost)}
|
||||
</EntityMetadataItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,8 +102,6 @@ EntityMetadataRow.defaultProps = {
|
||||
execTimePercent: undefined,
|
||||
timestamp: undefined,
|
||||
statusCode: undefined,
|
||||
tokens: undefined,
|
||||
cost: undefined,
|
||||
};
|
||||
|
||||
export default EntityMetadataRow;
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import { AiTokenCounts } from '../utils/aiTraceUtils';
|
||||
|
||||
import styles from './EntityMetadataRow.module.scss';
|
||||
|
||||
interface TokenUsageTooltipProps {
|
||||
tokens: AiTokenCounts;
|
||||
}
|
||||
|
||||
function TokenUsageTooltip({ tokens }: TokenUsageTooltipProps): JSX.Element {
|
||||
const { input, output, cacheRead, cacheWrite, reasoning } = tokens;
|
||||
|
||||
return (
|
||||
<div className={styles.tokenTooltip}>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
weight="normal"
|
||||
className={styles.tokenTooltipTitle}
|
||||
>
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
<TokenBreakdown
|
||||
input={input}
|
||||
output={output}
|
||||
cacheRead={cacheRead}
|
||||
cacheWrite={cacheWrite}
|
||||
reasoning={reasoning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenUsageTooltip;
|
||||
@@ -1,25 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--letter-spacing-wide);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import SpanMessages from '../../SpanMessages/SpanMessages';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasAnyMessages,
|
||||
hasFormattedMessages,
|
||||
} from '../../SpanMessages/utils';
|
||||
import { ThreadSpan, ThreadView } from '../../TraceDetailsThread/types';
|
||||
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
|
||||
import styles from './SpanAIMessages.module.scss';
|
||||
|
||||
interface SpanAIMessagesProps {
|
||||
span: ThreadSpan;
|
||||
}
|
||||
|
||||
/** Waterfall spans only carry raw attributes, so they get the JSON view alone. */
|
||||
function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const canFormat = hasFormattedMessages(data);
|
||||
const [view, setView] = useState(ThreadView.Formatted);
|
||||
const effectiveView = canFormat ? view : ThreadView.Json;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallIds = useMemo(() => buildToolCallIndex([span]), [span]);
|
||||
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
|
||||
scrollRef,
|
||||
toolCallIds,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="span-ai-messages">
|
||||
<div className={styles.header}>
|
||||
Messages
|
||||
{canFormat && (
|
||||
<ThreadViewToggle
|
||||
value={effectiveView}
|
||||
onChange={setView}
|
||||
testId="span-ai-messages-view-toggle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.scroll} ref={scrollRef}>
|
||||
<SpanMessages
|
||||
span={span}
|
||||
view={effectiveView}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanAIMessages;
|
||||
@@ -52,7 +52,6 @@ import Events from './Events/Events';
|
||||
import OpenInLogsExplorer from './SpanLogs/OpenInLogsExplorer';
|
||||
import SpanLogs from './SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from './SpanLogs/useSpanContextLogs';
|
||||
import SpanAIMessages from './SpanAIMessages/SpanAIMessages';
|
||||
import SpanSummary from './SpanSummary';
|
||||
|
||||
import styles from './SpanDetailsPanel.module.scss';
|
||||
@@ -321,7 +320,6 @@ function SpanDetailsContent({
|
||||
<div className={styles.tabsScroll}>
|
||||
<TabsContent value="overview">
|
||||
{isWide && summary}
|
||||
<SpanAIMessages span={selectedSpan} />
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
drawerKey="trace-details"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link2 } from '@signozhq/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import { getAIUsageDetails, convertToAiTokens } from '../utils/aiTraceUtils';
|
||||
import { HIGHLIGHTED_OPTIONS } from './config';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
@@ -33,10 +31,6 @@ function SpanSummary({
|
||||
}: SpanSummaryProps): JSX.Element {
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
const aiUsage = useMemo(
|
||||
() => getAIUsageDetails(selectedSpan)?.usage,
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,8 +63,6 @@ function SpanSummary({
|
||||
: undefined
|
||||
}
|
||||
timestamp={dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
|
||||
tokens={aiUsage && convertToAiTokens(aiUsage)}
|
||||
cost={aiUsage?.cost}
|
||||
/>
|
||||
|
||||
<div className={styles.spanInfoItem}>
|
||||
|
||||
@@ -95,26 +95,4 @@ describe('SpanSummary', () => {
|
||||
|
||||
expect(screen.queryByText(/of total exec time/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tokens and cost from the gen_ai usage attributes', () => {
|
||||
const span = {
|
||||
...createMockSpan(),
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 1000,
|
||||
'gen_ai.usage.output_tokens': 9,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.000009,
|
||||
},
|
||||
};
|
||||
render(<SpanSummary selectedSpan={span} />);
|
||||
|
||||
expect(screen.getByText(/Tokens: 1,000 → 9/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cost — \$ 0.000009/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits tokens and cost for non-AI spans', () => {
|
||||
render(<SpanSummary selectedSpan={createMockSpan()} />);
|
||||
|
||||
expect(screen.queryByText(/Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { getAiSpanKind } from '../utils/aiTraceUtils';
|
||||
import { AI_SPAN_BADGES } from './constants';
|
||||
|
||||
interface SpanHintBadgeProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
function SpanHintBadge({ span }: SpanHintBadgeProps): JSX.Element | null {
|
||||
const aiSpanKind = getAiSpanKind(span);
|
||||
|
||||
if (aiSpanKind) {
|
||||
const { label, color } = AI_SPAN_BADGES[aiSpanKind];
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// The outline variant derives text, fill and border from this color.
|
||||
style={{ '--badge-background': color } as CSSProperties}
|
||||
data-testid={`span-hint-badge-${aiSpanKind}`}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!span.response_status_code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HttpStatusBadge statusCode={span.response_status_code} />;
|
||||
}
|
||||
|
||||
export default SpanHintBadge;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { AiSpanKind } from '../utils/aiTraceUtils';
|
||||
|
||||
export const AI_SPAN_BADGES: Record<
|
||||
AiSpanKind,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
[AiSpanKind.Llm]: { label: 'LLM', color: 'rgba(155, 255, 0, 1)' },
|
||||
[AiSpanKind.Tool]: { label: 'TOOL', color: 'rgba(255, 209, 0, 1)' },
|
||||
[AiSpanKind.Agent]: { label: 'AGENT', color: 'rgba(255, 10, 138, 1)' },
|
||||
};
|
||||
@@ -8,12 +8,16 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l2-foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.name {
|
||||
|
||||
@@ -9,24 +9,40 @@
|
||||
}
|
||||
|
||||
.popover {
|
||||
composes: tooltipContent from '../shared.module.scss';
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
composes: tooltipBody from '../shared.module.scss';
|
||||
min-width: 223px;
|
||||
max-width: 360px;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
composes: tooltipTitle from '../shared.module.scss';
|
||||
.name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--spacing-2);
|
||||
.row {
|
||||
line-height: 1.5;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.preview {
|
||||
// container for additional preview rows
|
||||
}
|
||||
|
||||
.previewKey {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.previewValue {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
@@ -13,11 +12,6 @@ import { useMemo } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { getAIUsageDetails, SpanAiDetails } from '../utils/aiTraceUtils';
|
||||
import SpanUsageBreakdown from './SpanUsageBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
import styles from './SpanHoverCard.module.scss';
|
||||
|
||||
/**
|
||||
@@ -44,7 +38,6 @@ export interface SpanTooltipContentProps {
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
ai?: SpanAiDetails;
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -54,54 +47,33 @@ export function SpanTooltipContent({
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
ai,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.header}>
|
||||
<Typography.Text
|
||||
className={styles.title}
|
||||
size="small"
|
||||
weight="normal"
|
||||
style={{ color }}
|
||||
>
|
||||
{spanName}
|
||||
</Typography.Text>
|
||||
{ai?.usage && (
|
||||
<Typography.Text size="small" weight="normal">
|
||||
Usage Breakdown
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className={styles.name} style={{ color }}>
|
||||
{spanName}
|
||||
</div>
|
||||
<div className={styles.row}>status: {hasError ? 'error' : 'ok'}</div>
|
||||
<div className={styles.row}>start: {toFixed(relativeStartMs, 2)} ms</div>
|
||||
<div className={styles.row}>
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
{ai?.usage && <SpanUsageBreakdown usage={ai.usage} />}
|
||||
<TraceTooltipSection>
|
||||
{ai?.model && <TraceTooltipRow label="model" value={ai.model} />}
|
||||
{ai?.toolName && <TraceTooltipRow label="tool" value={ai.toolName} />}
|
||||
{ai?.agentName && <TraceTooltipRow label="agent" value={ai.agentName} />}
|
||||
<TraceTooltipRow label="status" value={hasError ? 'error' : 'ok'} />
|
||||
<TraceTooltipRow
|
||||
label="start"
|
||||
value={`${toFixed(relativeStartMs, 2)} ms`}
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label="duration"
|
||||
value={`${toFixed(formattedDuration, 2)} ${timeUnitName}`}
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<TraceTooltipSection>
|
||||
<div className={styles.preview}>
|
||||
{previewRows.map((row) => (
|
||||
<TraceTooltipRow
|
||||
<div
|
||||
key={row.key}
|
||||
label={row.key}
|
||||
value={row.value}
|
||||
testId={`span-hover-card-preview-${row.key}`}
|
||||
/>
|
||||
className={styles.row}
|
||||
data-testid={`span-hover-card-preview-${row.key}`}
|
||||
>
|
||||
<span className={styles.previewKey}>{row.key}:</span>{' '}
|
||||
<span className={styles.previewValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</TraceTooltipSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -165,7 +137,6 @@ export function SpanHoverCard({
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
ai: getAIUsageDetails(span),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { formatCost, hasValue, SpanAiUsage } from '../utils/aiTraceUtils';
|
||||
import TokenBreakdown from '../TokenBreakdown/TokenBreakdown';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
interface SpanUsageBreakdownProps {
|
||||
usage: SpanAiUsage;
|
||||
}
|
||||
|
||||
function SpanUsageBreakdown({ usage }: SpanUsageBreakdownProps): JSX.Element {
|
||||
const {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
reasoningTokens,
|
||||
cost,
|
||||
} = usage;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TokenBreakdown
|
||||
input={inputTokens}
|
||||
output={outputTokens}
|
||||
cacheRead={cacheReadTokens}
|
||||
cacheWrite={cacheCreationTokens}
|
||||
reasoning={reasoningTokens}
|
||||
/>
|
||||
{hasValue(cost) && (
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow isHeading label="Cost" value={formatCost(cost)} />
|
||||
</TraceTooltipSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpanUsageBreakdown;
|
||||
@@ -1,67 +0,0 @@
|
||||
import AIThreadMessage, {
|
||||
AIThreadMessageProps,
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage, ThreadView } from '../TraceDetailsThread/types';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
type MessageProps = Pick<
|
||||
AIThreadMessageProps,
|
||||
'isOutput' | 'isToolLinkEnabled' | 'onToolLinkClick'
|
||||
>;
|
||||
|
||||
interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
isOutput,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{isFormatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
// Messages have no stable id and never reorder.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
message={message}
|
||||
isOutput={isOutput}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
@@ -1,21 +0,0 @@
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { RawMessagesValue } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface RawMessagesProps {
|
||||
value: RawMessagesValue;
|
||||
}
|
||||
|
||||
function RawMessages({ value }: RawMessagesProps): JSX.Element {
|
||||
if (typeof value === 'object') {
|
||||
return <JsonBlock data={value} />;
|
||||
}
|
||||
return (
|
||||
<pre className={styles.raw} data-testid="span-raw-messages">
|
||||
{value}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default RawMessages;
|
||||
@@ -1,49 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-10);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.raw {
|
||||
margin: 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
color: var(--l1-foreground);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.missing {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
font-style: italic;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { AIThreadMessageProps } from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import { getSpanMessages, hasAnyMessages } from './utils';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
interface SpanMessagesProps extends Pick<
|
||||
AIThreadMessageProps,
|
||||
'isToolLinkEnabled' | 'onToolLinkClick'
|
||||
> {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Input and output messages of one AI span, shared by Thread and Span Details. */
|
||||
function SpanMessages({
|
||||
span,
|
||||
view,
|
||||
className,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
const callbacks = { isToolLinkEnabled, onToolLinkClick };
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
return (
|
||||
<div className={styles.missing}>
|
||||
This span has no input or output messages.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(styles.root, className)}>
|
||||
<MessagesSection
|
||||
title={isFormatted ? undefined : 'Input'}
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
testId="span-messages-input"
|
||||
{...callbacks}
|
||||
/>
|
||||
<MessagesSection
|
||||
title={isFormatted ? undefined : 'Output'}
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
isOutput
|
||||
testId="span-messages-output"
|
||||
{...callbacks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SpanMessages.defaultProps = {
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default SpanMessages;
|
||||
@@ -1,92 +0,0 @@
|
||||
import { parseJsonObject } from '../AIThreadMessage/utils';
|
||||
import {
|
||||
MessagePart,
|
||||
ThreadMessage,
|
||||
ThreadSpan,
|
||||
} from '../TraceDetailsThread/types';
|
||||
import { GEN_AI_KEYS } from '../utils/aiTraceUtils';
|
||||
|
||||
/** Parsed JSON, or the original string when it isn't JSON. */
|
||||
export type RawMessagesValue = object | string;
|
||||
|
||||
export interface SpanMessagesData {
|
||||
formattedInput: ThreadMessage[];
|
||||
formattedOutput: ThreadMessage[];
|
||||
rawInput?: RawMessagesValue;
|
||||
rawOutput?: RawMessagesValue;
|
||||
}
|
||||
|
||||
function toRawValue(value: unknown): RawMessagesValue | undefined {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return parseJsonObject(value) ?? value;
|
||||
}
|
||||
return typeof value === 'object' ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toFallbackPart(part: unknown): MessagePart {
|
||||
if (typeof part === 'string') {
|
||||
return { type: 'text', content: part };
|
||||
}
|
||||
if (isRecord(part) && part.type === 'text') {
|
||||
const text = part.text ?? part.content;
|
||||
if (typeof text === 'string') {
|
||||
return { type: 'text', content: text };
|
||||
}
|
||||
}
|
||||
return { type: 'generic', content: JSON.stringify(part, null, 2) };
|
||||
}
|
||||
|
||||
function toFallbackMessage(item: unknown): ThreadMessage {
|
||||
if (!isRecord(item)) {
|
||||
return { content: [toFallbackPart(item)] };
|
||||
}
|
||||
const body = item.parts ?? item.content;
|
||||
return {
|
||||
role: typeof item.role === 'string' ? item.role : undefined,
|
||||
content: Array.isArray(body)
|
||||
? body.map(toFallbackPart)
|
||||
: [toFallbackPart(body ?? item)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort messages from a raw `gen_ai.*.messages` value. */
|
||||
function toFallbackMessages(raw?: RawMessagesValue): ThreadMessage[] {
|
||||
if (raw === undefined) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(raw)
|
||||
? raw.map(toFallbackMessage)
|
||||
: [toFallbackMessage(raw)];
|
||||
}
|
||||
|
||||
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
|
||||
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
|
||||
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
|
||||
|
||||
// TODO: drop the raw-key fallback once the thread API sends formatted_input/output (#13003).
|
||||
return {
|
||||
formattedInput: span.formatted_input?.length
|
||||
? span.formatted_input
|
||||
: toFallbackMessages(rawInput),
|
||||
formattedOutput: span.formatted_output?.length
|
||||
? span.formatted_output
|
||||
: toFallbackMessages(rawOutput),
|
||||
rawInput,
|
||||
rawOutput,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFormattedMessages(data: SpanMessagesData): boolean {
|
||||
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
|
||||
}
|
||||
|
||||
export function hasAnyMessages(data: SpanMessagesData): boolean {
|
||||
return hasFormattedMessages(data) || !!data.rawInput || !!data.rawOutput;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
AiTokenCounts,
|
||||
formatTokens,
|
||||
getTokenLabel,
|
||||
getTotalInputTokens,
|
||||
} from '../utils/aiTraceUtils';
|
||||
import TraceTooltipRow from '../TraceTooltip/TraceTooltipRow';
|
||||
import TraceTooltipSection from '../TraceTooltip/TraceTooltipSection';
|
||||
|
||||
type TokenBreakdownProps = AiTokenCounts;
|
||||
|
||||
function TokenBreakdown({
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
reasoning,
|
||||
}: TokenBreakdownProps): JSX.Element {
|
||||
const totalInput = getTotalInputTokens({ input, cacheRead, cacheWrite });
|
||||
|
||||
return (
|
||||
<>
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Total Input', totalInput)}
|
||||
value={formatTokens(totalInput)}
|
||||
isHeading
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Read', cacheRead)}
|
||||
value={formatTokens(cacheRead)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Cache Write', cacheWrite)}
|
||||
value={formatTokens(cacheWrite)}
|
||||
isNested
|
||||
/>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Input', input)}
|
||||
value={formatTokens(input)}
|
||||
isNested
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Reasoning', reasoning)}
|
||||
value={formatTokens(reasoning)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label={getTokenLabel('Output', output)}
|
||||
value={formatTokens(output)}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
|
||||
<TraceTooltipSection>
|
||||
<TraceTooltipRow
|
||||
label="Total Token Usage"
|
||||
value={formatTokens(totalInput + (output ?? 0))}
|
||||
isHeading
|
||||
/>
|
||||
</TraceTooltipSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TokenBreakdown.defaultProps = {
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
reasoning: undefined,
|
||||
};
|
||||
|
||||
export default TokenBreakdown;
|
||||
@@ -34,15 +34,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: unset;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.filterSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
@@ -18,26 +18,22 @@ import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { useTraceStore } from '../stores/traceStore';
|
||||
import TraceDownloadPanel from './TraceDownloadPanel';
|
||||
import EntityMetadataRow from '../EntityMetadata/EntityMetadataRow';
|
||||
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import { useTraceDetailsStripInfo } from '../useTraceDetailsStripInfo';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
import TraceDetailsTabs from './TraceDetailsTabs';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
showTraceDetailsHeaderOptions?: boolean;
|
||||
}
|
||||
|
||||
@@ -69,14 +65,6 @@ function TraceDetailsHeader({
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const { data: traceSummary } = useTraceSummary(traceID);
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
|
||||
// Overview-only panels must not linger over another tab.
|
||||
useEffect(() => {
|
||||
setIsPreviewFieldsOpen(false);
|
||||
setIsAnalyticsOpen(false);
|
||||
}, [tab]);
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -110,14 +98,13 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useTraceDetailsStripInfo({
|
||||
totalSpansCount: traceSummary?.totalSpansCount ?? 0,
|
||||
totalErrorSpansCount: traceSummary?.totalErrorSpansCount ?? 0,
|
||||
});
|
||||
|
||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
const durationMs = traceSummary
|
||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
@@ -138,10 +125,9 @@ function TraceDetailsHeader({
|
||||
badgeValue={traceID}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
<TraceDetailsTabs />
|
||||
</div>
|
||||
)}
|
||||
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (
|
||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -195,32 +181,28 @@ function TraceDetailsHeader({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(showTraceDetails || !isOverview) && (
|
||||
{showTraceDetails && (
|
||||
<div className={styles.subHeader}>
|
||||
{!traceSummary ? (
|
||||
<DetailsLoader />
|
||||
) : (
|
||||
{traceSummary ? (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceSummary.rootServiceName,
|
||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={
|
||||
traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
}
|
||||
durationMs={durationMs}
|
||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
tokens={traceSummary.ai?.tokens}
|
||||
cost={traceSummary.ai?.totalCost}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOverview && traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
|
||||
function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (value) {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={tab}
|
||||
onChange={handleChange}
|
||||
className={styles.tabs}
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Overview}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-overview"
|
||||
>
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value={TraceDetailsTab.Thread}
|
||||
className={styles.tab}
|
||||
data-testid="trace-details-tab-thread"
|
||||
>
|
||||
Thread
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsTabs;
|
||||
@@ -1,8 +1,6 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
@@ -178,19 +176,9 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
totalErrorSpansCount: 0,
|
||||
ai: {
|
||||
tokens: {
|
||||
input: 12040,
|
||||
output: 3110,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
reasoning: 0,
|
||||
},
|
||||
totalCost: 0.0421,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSummary = (data?: SpantypesGettableTraceSummaryDTO): void => {
|
||||
const mockSummary = (data?: typeof traceMetadata): void => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
|
||||
};
|
||||
|
||||
@@ -213,77 +201,6 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(screen.getByText(duration)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders AI tokens and cost when the summary has them', () => {
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits AI tokens and cost when the summary has no ai field', () => {
|
||||
mockSummary({ ...traceMetadata, ai: undefined });
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.queryByText(/^Tokens:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows AI tokens when input and output are both zero', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: 0, output: 0 },
|
||||
},
|
||||
} as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cost — $ 0.0421')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero for a missing input', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: { ...traceMetadata.ai.tokens, input: undefined },
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 3,110')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows zero AI tokens when input and output are both missing', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: {
|
||||
...traceMetadata.ai,
|
||||
tokens: {
|
||||
...traceMetadata.ai.tokens,
|
||||
input: undefined,
|
||||
output: undefined,
|
||||
},
|
||||
},
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 0 → 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the cost when the summary sends null', () => {
|
||||
mockSummary({
|
||||
...traceMetadata,
|
||||
ai: { ...traceMetadata.ai, totalCost: null },
|
||||
} as unknown as SpantypesGettableTraceSummaryDTO);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText('Tokens: 12,040 → 3,110')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^Cost —/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
mockSummary(traceMetadata);
|
||||
@@ -316,96 +233,3 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
const metadataText = /Missing Span/;
|
||||
|
||||
describe('TraceDetailsHeader – tabs', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({
|
||||
data: {
|
||||
...SUMMARY,
|
||||
rootServiceEntryPoint: 'Missing Span',
|
||||
hasMissingSpans: true,
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest
|
||||
.mocked(useTraceSummary)
|
||||
.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
// In-memory URL state, so a tab switch can't leak into the next test.
|
||||
const renderHeader = (): void => {
|
||||
render(
|
||||
<NuqsTestingAdapter hasMemory>
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
};
|
||||
|
||||
it('shows the Overview-only sections on the Overview tab', () => {
|
||||
renderHeader();
|
||||
|
||||
expect(screen.getByTestId('filters-stub')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /trace options/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('missing-spans-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps only the metadata row on the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.queryByTestId('filters-stub')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /trace options/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('missing-spans-banner')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the metadata row on the Thread tab even when hidden on Overview', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /trace options/i }));
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: /hide trace details/i }),
|
||||
);
|
||||
expect(screen.queryByText(metadataText)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByText(metadataText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes the Analytics panel when switching to the Thread tab', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderHeader();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^analytics$/i }));
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('trace-details-tab-thread'));
|
||||
|
||||
expect(screen.getByTestId('analytics-panel')).toHaveAttribute(
|
||||
'data-open',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,495 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from '../events';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
|
||||
import NoData from '../NoData/NoData';
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
useTraceStore,
|
||||
} from '../stores/traceStore';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from '../TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from '../TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from '../TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from '../TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from '../TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from '../utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsOverview.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
interface TraceDetailsOverviewProps {
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
onHasTraceDataChange: (hasTraceData: boolean) => void;
|
||||
}
|
||||
|
||||
function TraceDetailsOverview({
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
onHasTraceDataChange,
|
||||
}: TraceDetailsOverviewProps): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
params.delete('spanId');
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
}, [safeNavigate]);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, handleVariantChange] = useSpanDetailVariant();
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const hasTraceData = !showNoData && allSpans.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onHasTraceDataChange(hasTraceData);
|
||||
return (): void => onHasTraceDataChange(false);
|
||||
}, [hasTraceData, onHasTraceDataChange]);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsOverview;
|
||||
@@ -1,32 +0,0 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-16);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 420px;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './ThreadEmptyState.module.scss';
|
||||
|
||||
interface ThreadEmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function ThreadEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
testId,
|
||||
}: ThreadEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid={testId}>
|
||||
<span className={styles.icon}>{icon}</span>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<span className={styles.description}>{description}</span>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ThreadEmptyState.defaultProps = {
|
||||
actions: undefined,
|
||||
};
|
||||
|
||||
export default ThreadEmptyState;
|
||||
@@ -1,40 +0,0 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-20) var(--spacing-24);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 428px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--paragraph-base-500-line-height);
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-family: var(--paragraph-base-500-font-family);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: var(--paragraph-base-500-letter-spacing);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
composes: text;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
|
||||
|
||||
import styles from './ThreadNoAiSpans.module.scss';
|
||||
|
||||
function ThreadNoAiSpans(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid="thread-empty">
|
||||
<div className={styles.message}>
|
||||
<span className={styles.emoji} aria-hidden>
|
||||
🚏
|
||||
</span>
|
||||
<div className={styles.paragraphs}>
|
||||
<p className={styles.text}>
|
||||
Looks like this trace does not have any spans from AI-related actions.
|
||||
</p>
|
||||
<p className={styles.highlight}>
|
||||
Thread view summarising your entire AI-journey history will appear here
|
||||
when the spans have the necessary datapoints.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
suffix={<ArrowRight size={12} />}
|
||||
onClick={(): void => {
|
||||
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
|
||||
}}
|
||||
testId="thread-empty-learn-more"
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadNoAiSpans;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
|
||||
import styles from './ThreadSpanList.module.scss';
|
||||
|
||||
interface ThreadPageLoaderProps {
|
||||
testId: string;
|
||||
}
|
||||
|
||||
function ThreadPageLoader({ testId }: ThreadPageLoaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.pageLoader} data-testid={testId}>
|
||||
<Skeleton active title={{ width: '30%' }} paragraph={{ rows: 2 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadPageLoader;
|
||||
@@ -1,16 +0,0 @@
|
||||
.card {
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l1-background);
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import SpanMessages from '../SpanMessages/SpanMessages';
|
||||
import { ThreadSpan, ThreadView } from './types';
|
||||
|
||||
import styles from './ThreadSpanCard.module.scss';
|
||||
|
||||
interface ThreadSpanCardProps {
|
||||
span: ThreadSpan;
|
||||
view: ThreadView;
|
||||
isSelected: boolean;
|
||||
onSelect: (span: ThreadSpan) => void;
|
||||
isToolLinkEnabled: (toolCallId: string) => boolean;
|
||||
onToolLinkClick: (toolCallId: string) => void;
|
||||
}
|
||||
|
||||
function ThreadSpanCard({
|
||||
span,
|
||||
view,
|
||||
isSelected,
|
||||
onSelect,
|
||||
isToolLinkEnabled,
|
||||
onToolLinkClick,
|
||||
}: ThreadSpanCardProps): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
|
||||
<article
|
||||
className={cx(styles.card, isSelected && styles.isSelected)}
|
||||
onClick={(): void => onSelect(span)}
|
||||
data-span-id={span.span_id}
|
||||
data-testid="thread-span-card"
|
||||
>
|
||||
<SpanMessages
|
||||
span={span}
|
||||
view={view}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ThreadSpanCard);
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import SpanDetailsPanel from '../SpanDetailsPanel/SpanDetailsPanel';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { useTraceSummary } from '../TraceDetailsHeader/useTraceSummary';
|
||||
import { ThreadSpan } from './types';
|
||||
|
||||
import styles from './TraceDetailsThread.module.scss';
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const BOTTOM_DOCK_DEFAULT = 360;
|
||||
const BOTTOM_DOCK_MIN = 200;
|
||||
|
||||
interface ThreadSpanDetailsProps {
|
||||
panelState: DetailsPanelState;
|
||||
span: ThreadSpan;
|
||||
variant: SpanDetailVariant;
|
||||
onVariantChange: (variant: SpanDetailVariant) => void;
|
||||
}
|
||||
|
||||
function ThreadSpanDetails({
|
||||
panelState,
|
||||
span,
|
||||
variant,
|
||||
onVariantChange,
|
||||
}: ThreadSpanDetailsProps): JSX.Element {
|
||||
const { id: traceId = '' } = useParams<TraceDetailV3URLProps>();
|
||||
const { data: summary } = useTraceSummary(traceId);
|
||||
const [width, setWidth] = useState(RIGHT_DOCK_MIN);
|
||||
const [height, setHeight] = useState(BOTTOM_DOCK_DEFAULT);
|
||||
|
||||
const panel = (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={span}
|
||||
variant={variant}
|
||||
onVariantChange={onVariantChange}
|
||||
traceStartTime={summary?.startTimestampMillis}
|
||||
traceEndTime={summary?.endTimestampMillis}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED_RIGHT) {
|
||||
return (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={width}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
{panel}
|
||||
</ResizableBox>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === SpanDetailVariant.DOCKED) {
|
||||
return (
|
||||
<ResizableBox
|
||||
handle="top"
|
||||
defaultHeight={height}
|
||||
minHeight={BOTTOM_DOCK_MIN}
|
||||
onResize={setHeight}
|
||||
className={styles.bottomDock}
|
||||
>
|
||||
{panel}
|
||||
</ResizableBox>
|
||||
);
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
export default ThreadSpanDetails;
|
||||
@@ -1,23 +0,0 @@
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
// Prepend compensation is manual; native anchoring would double-shift.
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
flex-shrink: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.pageLoader {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ReactNode, RefObject } from 'react';
|
||||
|
||||
import { useThreadInfiniteScroll } from './hooks/useThreadInfiniteScroll';
|
||||
import ThreadPageLoader from './ThreadPageLoader';
|
||||
|
||||
import styles from './ThreadSpanList.module.scss';
|
||||
|
||||
interface ThreadSpanListProps {
|
||||
listRef: RefObject<HTMLDivElement>;
|
||||
firstItemKey?: string;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function ThreadSpanList({
|
||||
listRef,
|
||||
firstItemKey,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
children,
|
||||
}: ThreadSpanListProps): JSX.Element {
|
||||
const { topSentinelRef, bottomSentinelRef } = useThreadInfiniteScroll({
|
||||
containerRef: listRef,
|
||||
firstItemKey,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.list} ref={listRef} data-testid="thread-span-list">
|
||||
<div ref={topSentinelRef} className={styles.sentinel} />
|
||||
{isFetchingPreviousPage && (
|
||||
<ThreadPageLoader testId="thread-loading-previous" />
|
||||
)}
|
||||
{children}
|
||||
{isFetchingNextPage && <ThreadPageLoader testId="thread-loading-next" />}
|
||||
<div ref={bottomSentinelRef} className={styles.sentinel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadSpanList;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { ArrowLeft, MessagesSquare, SearchX } from '@signozhq/icons';
|
||||
|
||||
import ThreadEmptyState from './ThreadEmptyState';
|
||||
|
||||
interface ThreadSpanNotFoundProps {
|
||||
onGoBack: () => void;
|
||||
onShowThread: () => void;
|
||||
}
|
||||
|
||||
function ThreadSpanNotFound({
|
||||
onGoBack,
|
||||
onShowThread,
|
||||
}: ThreadSpanNotFoundProps): JSX.Element {
|
||||
return (
|
||||
<ThreadEmptyState
|
||||
icon={<SearchX size={32} />}
|
||||
title="Span not found in this thread"
|
||||
description="The requested span isn't part of this trace's AI thread."
|
||||
testId="thread-span-not-found"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onGoBack}
|
||||
testId="thread-span-not-found-go-back"
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<MessagesSquare size={14} />}
|
||||
onClick={onShowThread}
|
||||
testId="thread-span-not-found-show-thread"
|
||||
>
|
||||
Show thread
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThreadSpanNotFound;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { ThreadView } from './types';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ label: 'Formatted', value: ThreadView.Formatted },
|
||||
{ label: 'JSON', value: ThreadView.Json },
|
||||
];
|
||||
|
||||
interface ThreadViewToggleProps {
|
||||
value: ThreadView;
|
||||
onChange: (view: ThreadView) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ThreadViewToggle({
|
||||
value,
|
||||
onChange,
|
||||
testId,
|
||||
}: ThreadViewToggleProps): JSX.Element {
|
||||
const handleChange = (next: string): void => {
|
||||
// Clicking the active item deselects it and reports ''.
|
||||
if (next) {
|
||||
onChange(next as ThreadView);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
items={VIEW_OPTIONS}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ThreadViewToggle.defaultProps = {
|
||||
testId: 'thread-view-toggle',
|
||||
};
|
||||
|
||||
export default ThreadViewToggle;
|
||||
@@ -1,50 +0,0 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: var(--spacing-6) var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.initialLoader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
.bottomDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.rightDock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { useToolCallLinks } from '../AIThreadMessage/useToolCallLinks';
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
|
||||
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
import { useScrollToAnchorSpan } from './hooks/useScrollToAnchorSpan';
|
||||
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
|
||||
import { useThreadView } from './hooks/useThreadView';
|
||||
import { useTraceThread } from './hooks/useTraceThread';
|
||||
import ThreadNoAiSpans from './ThreadNoAiSpans';
|
||||
import ThreadPageLoader from './ThreadPageLoader';
|
||||
import ThreadSpanCard from './ThreadSpanCard';
|
||||
import ThreadSpanDetails from './ThreadSpanDetails';
|
||||
import ThreadSpanList from './ThreadSpanList';
|
||||
import ThreadSpanNotFound from './ThreadSpanNotFound';
|
||||
import ThreadViewToggle from './ThreadViewToggle';
|
||||
import { AnchorStatus } from './types';
|
||||
import { buildToolCallIndex, getAnchorStatus } from './utils';
|
||||
|
||||
import styles from './TraceDetailsThread.module.scss';
|
||||
|
||||
const INITIAL_LOADER_COUNT = 3;
|
||||
|
||||
function TraceDetailsThread(): JSX.Element {
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const [, setTab] = useTraceDetailsTab();
|
||||
const [view, setView] = useThreadView();
|
||||
const { anchorSpanId, clearAnchor, selectedSpanId, selectSpan, panelState } =
|
||||
useThreadSpanSelection();
|
||||
const thread = useTraceThread(traceId || '', anchorSpanId);
|
||||
const { spans, isLoading, isError } = thread;
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const toolCallIds = useMemo(() => buildToolCallIndex(spans), [spans]);
|
||||
const { isToolLinkEnabled, onToolLinkClick } = useToolCallLinks(
|
||||
listRef,
|
||||
toolCallIds,
|
||||
);
|
||||
|
||||
const anchorStatus = getAnchorStatus(anchorSpanId, spans, isLoading);
|
||||
useScrollToAnchorSpan(
|
||||
listRef,
|
||||
anchorSpanId,
|
||||
anchorStatus === AnchorStatus.Found,
|
||||
);
|
||||
|
||||
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
|
||||
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
|
||||
const spanDetails = panelState.isOpen && selectedSpan && (
|
||||
<ThreadSpanDetails
|
||||
panelState={panelState}
|
||||
span={selectedSpan}
|
||||
variant={spanDetailVariant}
|
||||
onVariantChange={setSpanDetailVariant}
|
||||
/>
|
||||
);
|
||||
const hasNoThread =
|
||||
!isLoading &&
|
||||
anchorStatus !== AnchorStatus.NotFound &&
|
||||
(isError || spans.length === 0);
|
||||
|
||||
const renderBody = (): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.initialLoader} data-testid="thread-loading">
|
||||
{Array.from({ length: INITIAL_LOADER_COUNT }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<ThreadPageLoader key={i} testId="thread-loading-item" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (anchorStatus === AnchorStatus.NotFound) {
|
||||
return (
|
||||
<ThreadSpanNotFound
|
||||
onGoBack={(): void => {
|
||||
void setTab(TraceDetailsTab.Overview);
|
||||
}}
|
||||
onShowThread={clearAnchor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hasNoThread) {
|
||||
return <ThreadNoAiSpans />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{anchorStatus === AnchorStatus.NoMessages && (
|
||||
<Callout
|
||||
type="info"
|
||||
size="small"
|
||||
className={styles.notice}
|
||||
title="The requested span has no input or output messages. Showing the rest of the thread."
|
||||
testId="thread-anchor-no-messages"
|
||||
/>
|
||||
)}
|
||||
<ThreadSpanList
|
||||
listRef={listRef}
|
||||
firstItemKey={spans[0]?.span_id}
|
||||
hasNextPage={thread.hasNextPage}
|
||||
hasPreviousPage={thread.hasPreviousPage}
|
||||
isFetchingNextPage={thread.isFetchingNextPage}
|
||||
isFetchingPreviousPage={thread.isFetchingPreviousPage}
|
||||
fetchNextPage={thread.fetchNextPage}
|
||||
fetchPreviousPage={thread.fetchPreviousPage}
|
||||
>
|
||||
{spans.map((span) => (
|
||||
<ThreadSpanCard
|
||||
key={span.span_id}
|
||||
span={span}
|
||||
view={view}
|
||||
isSelected={span.span_id === selectedSpanId}
|
||||
onSelect={selectSpan}
|
||||
isToolLinkEnabled={isToolLinkEnabled}
|
||||
onToolLinkClick={onToolLinkClick}
|
||||
/>
|
||||
))}
|
||||
</ThreadSpanList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root} data-testid="trace-details-thread">
|
||||
<div className={styles.content}>
|
||||
{!hasNoThread && (
|
||||
<div className={styles.toolbar}>
|
||||
<ThreadViewToggle
|
||||
value={view}
|
||||
onChange={(next): void => {
|
||||
void setView(next);
|
||||
}}
|
||||
/>
|
||||
{spans.length > 0 && <span>Showing {spans.length} spans</span>}
|
||||
</div>
|
||||
)}
|
||||
{renderBody()}
|
||||
{isBottomDocked && spanDetails}
|
||||
</div>
|
||||
{!isBottomDocked && spanDetails}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsThread;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
|
||||
const ROOT_MARGIN = '200px 0px';
|
||||
|
||||
/** Calls `onVisible` while enabled and the sentinel is within reach of the container's viewport. */
|
||||
export function useIntersectionSentinel(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
sentinelRef: RefObject<HTMLElement>,
|
||||
isEnabled: boolean,
|
||||
onVisible: () => void,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!isEnabled || !sentinel) {
|
||||
return undefined;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
onVisible();
|
||||
}
|
||||
},
|
||||
{ root: containerRef.current, rootMargin: ROOT_MARGIN },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return (): void => observer.disconnect();
|
||||
}, [containerRef, sentinelRef, isEnabled, onVisible]);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
/** Scrolls the anchor span's card into view once, after it first renders. */
|
||||
export function useScrollToAnchorSpan(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
anchorSpanId: string | undefined,
|
||||
isAnchorLoaded: boolean,
|
||||
): void {
|
||||
const scrolledForRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!anchorSpanId ||
|
||||
!isAnchorLoaded ||
|
||||
scrolledForRef.current === anchorSpanId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const card = containerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-span-id="${CSS.escape(anchorSpanId)}"]`,
|
||||
);
|
||||
if (card) {
|
||||
scrolledForRef.current = anchorSpanId;
|
||||
card.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
}, [containerRef, anchorSpanId, isAnchorLoaded]);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { RefObject, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { useIntersectionSentinel } from './useIntersectionSentinel';
|
||||
|
||||
interface UseThreadInfiniteScrollOptions {
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
firstItemKey?: string;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface UseThreadInfiniteScrollResult {
|
||||
topSentinelRef: RefObject<HTMLDivElement>;
|
||||
bottomSentinelRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/** Loads pages as either list edge nears view, holding position on prepend. */
|
||||
export function useThreadInfiniteScroll({
|
||||
containerRef,
|
||||
firstItemKey,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
isFetchingPreviousPage,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
}: UseThreadInfiniteScrollOptions): UseThreadInfiniteScrollResult {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const heightBeforePrependRef = useRef<number | null>(null);
|
||||
|
||||
const loadPreviousRef = useRef((): void => {});
|
||||
loadPreviousRef.current = (): void => {
|
||||
heightBeforePrependRef.current = containerRef.current?.scrollHeight ?? null;
|
||||
void fetchPreviousPage();
|
||||
};
|
||||
// Stable wrapper so the observer isn't recreated on every render.
|
||||
const stableLoadPrevious = useRef((): void =>
|
||||
loadPreviousRef.current(),
|
||||
).current;
|
||||
|
||||
useIntersectionSentinel(
|
||||
containerRef,
|
||||
bottomSentinelRef,
|
||||
hasNextPage && !isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
);
|
||||
useIntersectionSentinel(
|
||||
containerRef,
|
||||
topSentinelRef,
|
||||
hasPreviousPage && !isFetchingPreviousPage,
|
||||
stableLoadPrevious,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const previousHeight = heightBeforePrependRef.current;
|
||||
if (!container || previousHeight === null) {
|
||||
return;
|
||||
}
|
||||
heightBeforePrependRef.current = null;
|
||||
container.scrollTop += container.scrollHeight - previousHeight;
|
||||
}, [containerRef, firstItemKey]);
|
||||
|
||||
return { topSentinelRef, bottomSentinelRef };
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import { DetailsPanelState } from 'components/DetailsPanel/types';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { ThreadSpan } from '../types';
|
||||
|
||||
const SPAN_ID_PARAM = 'spanId';
|
||||
|
||||
interface ThreadSpanSelection {
|
||||
// The spanId the thread was opened with; it shapes the first page fetched.
|
||||
anchorSpanId?: string;
|
||||
clearAnchor: () => void;
|
||||
selectedSpanId?: string;
|
||||
selectSpan: (span: ThreadSpan) => void;
|
||||
panelState: DetailsPanelState;
|
||||
}
|
||||
|
||||
export function useThreadSpanSelection(): ThreadSpanSelection {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const selectedSpanId = urlQuery.get(SPAN_ID_PARAM) || undefined;
|
||||
const [anchorSpanId, setAnchorSpanId] = useState(selectedSpanId);
|
||||
|
||||
const setSpanIdParam = useCallback(
|
||||
(spanId?: string): void => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
if (spanId) {
|
||||
params.set(SPAN_ID_PARAM, spanId);
|
||||
} else {
|
||||
params.delete(SPAN_ID_PARAM);
|
||||
}
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(): void => setSpanIdParam(undefined),
|
||||
[setSpanIdParam],
|
||||
);
|
||||
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const { open: openPanel } = panelState;
|
||||
const selectSpan = useCallback(
|
||||
(span: ThreadSpan): void => {
|
||||
setSpanIdParam(span.span_id);
|
||||
openPanel();
|
||||
},
|
||||
[setSpanIdParam, openPanel],
|
||||
);
|
||||
|
||||
const clearAnchor = useCallback((): void => {
|
||||
setAnchorSpanId(undefined);
|
||||
setSpanIdParam(undefined);
|
||||
}, [setSpanIdParam]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
anchorSpanId,
|
||||
clearAnchor,
|
||||
selectedSpanId,
|
||||
selectSpan,
|
||||
panelState,
|
||||
}),
|
||||
[anchorSpanId, clearAnchor, selectedSpanId, selectSpan, panelState],
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
|
||||
import { ThreadView } from '../types';
|
||||
|
||||
export function useThreadView(): UseQueryStateReturn<ThreadView, ThreadView> {
|
||||
return useQueryState(
|
||||
'view',
|
||||
parseAsStringEnum(Object.values(ThreadView)).withDefault(
|
||||
ThreadView.Formatted,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useInfiniteQuery } from 'react-query';
|
||||
import {
|
||||
getGetTraceThreadQueryKey,
|
||||
getTraceThread,
|
||||
} from 'api/generated/services/tracedetail';
|
||||
import type { GetTraceThreadParams } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ThreadSpan } from '../types';
|
||||
import { isNotFoundError, toThreadSpan } from '../utils';
|
||||
|
||||
const THREAD_PAGE_LIMIT = 100;
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
type PageParam = Pick<GetTraceThreadParams, 'after' | 'before'>;
|
||||
|
||||
interface UseTraceThreadResult {
|
||||
spans: ThreadSpan[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
isFetchingPreviousPage: boolean;
|
||||
fetchNextPage: () => Promise<unknown>;
|
||||
fetchPreviousPage: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function useTraceThread(
|
||||
traceId: string,
|
||||
anchorSpanId?: string,
|
||||
): UseTraceThreadResult {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: getGetTraceThreadQueryKey(
|
||||
{ traceID: traceId },
|
||||
{ spanId: anchorSpanId },
|
||||
),
|
||||
queryFn: ({ pageParam, signal }) => {
|
||||
const { after, before } = (pageParam ?? {}) as PageParam;
|
||||
return getTraceThread(
|
||||
{ traceID: traceId },
|
||||
{
|
||||
limit: THREAD_PAGE_LIMIT,
|
||||
after,
|
||||
before,
|
||||
// The anchor only shapes the first page; cursors drive the rest.
|
||||
spanId: after || before ? undefined : anchorSpanId,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage): PageParam | undefined =>
|
||||
lastPage.data.nextCursor ? { after: lastPage.data.nextCursor } : undefined,
|
||||
getPreviousPageParam: (firstPage): PageParam | undefined =>
|
||||
firstPage.data.prevCursor
|
||||
? { before: firstPage.data.prevCursor }
|
||||
: undefined,
|
||||
// An unknown anchor span is a 404; retrying won't find it.
|
||||
retry: (failureCount, error): boolean =>
|
||||
!isNotFoundError(error) && failureCount < MAX_RETRIES,
|
||||
enabled: !!traceId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
() =>
|
||||
query.data?.pages.flatMap((page) => page.data.spans.map(toThreadSpan)) ?? [],
|
||||
[query.data],
|
||||
);
|
||||
|
||||
return {
|
||||
spans,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
hasPreviousPage: !!query.hasPreviousPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isFetchingPreviousPage: query.isFetchingPreviousPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
fetchPreviousPage: query.fetchPreviousPage,
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export type MessagePart =
|
||||
| { type: 'text'; content: string }
|
||||
| { type: 'thinking'; content?: string }
|
||||
| {
|
||||
type: 'tool_call';
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
server?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'media';
|
||||
modality?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
name?: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
| { type: 'generic'; content: string };
|
||||
|
||||
export interface ThreadMessage {
|
||||
// system | user | assistant | tool; any other value renders headerless.
|
||||
role?: string;
|
||||
content: MessagePart[];
|
||||
// stop | tool_call | length | content_filter | error, or provider-specific.
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
export type ThreadSpan = SpanV3 & {
|
||||
formatted_input?: ThreadMessage[];
|
||||
formatted_output?: ThreadMessage[];
|
||||
};
|
||||
|
||||
export enum ThreadView {
|
||||
Formatted = 'formatted',
|
||||
Json = 'json',
|
||||
}
|
||||
|
||||
export enum AnchorStatus {
|
||||
None = 'none',
|
||||
Loading = 'loading',
|
||||
Found = 'found',
|
||||
// Backend anchors on the span's position but drops it for lacking messages.
|
||||
NoMessages = 'noMessages',
|
||||
NotFound = 'notFound',
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { isAxiosError } from 'axios';
|
||||
|
||||
import { collectToolCallIds } from '../AIThreadMessage/utils';
|
||||
import { AnchorStatus, ThreadSpan } from './types';
|
||||
|
||||
type WireThreadSpan = Omit<ThreadSpan, 'timestamp' | 'service.name'> & {
|
||||
time_unix: number;
|
||||
};
|
||||
|
||||
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
|
||||
export function toThreadSpan(span: SpantypesThreadSpanDTO): ThreadSpan {
|
||||
const wire = span as unknown as WireThreadSpan;
|
||||
return {
|
||||
...wire,
|
||||
'service.name': wire.resource?.['service.name'] || '',
|
||||
timestamp: wire.time_unix,
|
||||
};
|
||||
}
|
||||
|
||||
export function isNotFoundError(error: unknown): boolean {
|
||||
return isAxiosError(error) && error.response?.status === 404;
|
||||
}
|
||||
|
||||
export function buildToolCallIndex(spans: ThreadSpan[]): Set<string> {
|
||||
return new Set(
|
||||
spans.flatMap((span) => [
|
||||
...collectToolCallIds(span.formatted_input ?? []),
|
||||
...collectToolCallIds(span.formatted_output ?? []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function getAnchorStatus(
|
||||
anchorSpanId: string | undefined,
|
||||
spans: ThreadSpan[],
|
||||
isLoading: boolean,
|
||||
): AnchorStatus {
|
||||
if (!anchorSpanId) {
|
||||
return AnchorStatus.None;
|
||||
}
|
||||
if (isLoading) {
|
||||
return AnchorStatus.Loading;
|
||||
}
|
||||
if (spans.length === 0) {
|
||||
return AnchorStatus.NotFound;
|
||||
}
|
||||
return spans.some((span) => span.span_id === anchorSpanId)
|
||||
? AnchorStatus.Found
|
||||
: AnchorStatus.NoMessages;
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layoutRow {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -1,6 +0,0 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import TraceStoreSync from '../stores/TraceStoreSync';
|
||||
|
||||
import styles from './TraceDetailsWrapper.module.scss';
|
||||
|
||||
interface TraceDetailsWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceDetailsWrapper({
|
||||
children,
|
||||
}: TraceDetailsWrapperProps): JSX.Element {
|
||||
return (
|
||||
<TraceStoreSync>
|
||||
<div className={styles.root}>{children}</div>
|
||||
</TraceStoreSync>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceDetailsWrapper;
|
||||
@@ -30,9 +30,13 @@
|
||||
// lives here. Shares visual treatment with the waterfall hover popover
|
||||
// but is positioned `fixed` instead of anchored.
|
||||
.tooltip {
|
||||
composes: tooltipSurface from '../shared.module.scss';
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { useHistory, useLocation, useParams } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
@@ -31,6 +30,7 @@ function TraceFlamegraph({
|
||||
const { id: traceId } = useParams<TraceDetailFlamegraphURLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const history = useHistory();
|
||||
const { search } = useLocation();
|
||||
const [firstSpanAtFetchLevel, setFirstSpanAtFetchLevel] = useState<string>(
|
||||
urlQuery.get('spanId') || '',
|
||||
);
|
||||
@@ -42,14 +42,14 @@ function TraceFlamegraph({
|
||||
const handleSpanClick = useCallback(
|
||||
(spanId: string): void => {
|
||||
setFirstSpanAtFetchLevel(spanId);
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
//tood: use from query params constants
|
||||
if (searchParams.get('spanId') !== spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
history.replace({ search: searchParams.toString() });
|
||||
}
|
||||
},
|
||||
[history],
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) 0;
|
||||
border-top: 1px solid;
|
||||
border-image: linear-gradient(
|
||||
90deg,
|
||||
var(--l3-background-transparent),
|
||||
var(--l3-border),
|
||||
var(--l3-background-transparent)
|
||||
)
|
||||
1;
|
||||
}
|
||||
|
||||
.section:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.section:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: space-between;
|
||||
column-gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nestedRow {
|
||||
padding-left: var(--spacing-6);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TraceTooltipRowProps {
|
||||
label: string;
|
||||
value?: string;
|
||||
isNested?: boolean;
|
||||
isHeading?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function TraceTooltipRow({
|
||||
label,
|
||||
value,
|
||||
isNested,
|
||||
isHeading,
|
||||
testId,
|
||||
}: TraceTooltipRowProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.row, isNested && styles.nestedRow)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Typography.Text
|
||||
size="small"
|
||||
color={isHeading ? undefined : 'muted'}
|
||||
className={styles.rowLabel}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
{value !== undefined && (
|
||||
<Typography.Text size="small" className={styles.rowValue}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceTooltipRow;
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import styles from './TraceTooltip.module.scss';
|
||||
|
||||
interface TraceTooltipSectionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function TraceTooltipSection({
|
||||
children,
|
||||
}: TraceTooltipSectionProps): JSX.Element {
|
||||
return <div className={styles.section}>{children}</div>;
|
||||
}
|
||||
|
||||
export default TraceTooltipSection;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { ArrowRightFromLine, Search, X } from '@signozhq/icons';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
@@ -18,7 +18,6 @@ import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import NozButton from 'pages/TraceDetailsV3/TraceDetailsHeader/NozButton';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
@@ -180,11 +179,12 @@ function Filters({
|
||||
const { selectedCategory, categories, handleCategoryChange } =
|
||||
useSpanCategoryFilter(filterProps);
|
||||
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const handlePrevNext = useCallback(
|
||||
(index: number, spanId?: string): void => {
|
||||
const searchParams = getUnstableCurrentSearchParams();
|
||||
const searchParams = new URLSearchParams(search);
|
||||
if (spanId) {
|
||||
searchParams.set('spanId', spanId);
|
||||
} else {
|
||||
@@ -193,7 +193,7 @@ function Filters({
|
||||
|
||||
history.replace({ search: searchParams.toString() });
|
||||
},
|
||||
[filteredSpanIds, history],
|
||||
[filteredSpanIds, history, search],
|
||||
);
|
||||
|
||||
const { isFetching, error } = useGetQueryRange(
|
||||
|
||||
@@ -30,8 +30,12 @@
|
||||
// loads cross-module composes through the plain CSS parser, which chokes on
|
||||
// SCSS `//` comments in the target file.
|
||||
.popover {
|
||||
composes: tooltipContent from '../../../shared.module.scss';
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
@@ -213,9 +217,8 @@
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding-left: var(--spacing-2);
|
||||
|
||||
--badge-border-radius: 2px;
|
||||
--badge-padding: 3px 6px;
|
||||
@@ -405,11 +408,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.aiSpanIcon {
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--spacing-2);
|
||||
}
|
||||
|
||||
.treeLabel {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter';
|
||||
|
||||
@@ -25,20 +25,15 @@ import {
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { convertTimeToRelevantUnit } from 'utils/traceUtils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Link,
|
||||
ListPlus,
|
||||
Sparkle,
|
||||
} from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
|
||||
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
|
||||
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
@@ -49,8 +44,6 @@ import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import SpanHintBadge from '../../../SpanHintBadge/SpanHintBadge';
|
||||
import { isAiSpan } from '../../../utils/aiTraceUtils';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../types';
|
||||
|
||||
@@ -335,23 +328,14 @@ const SpanOverview = memo(function SpanOverview({
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
{isAiSpan(span) ? (
|
||||
<Sparkle
|
||||
size={10}
|
||||
color={effectiveColor}
|
||||
fill={effectiveColor}
|
||||
className={styles.aiSpanIcon}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cx(styles.treeIcon, { [styles.hasError]: span.has_error })}
|
||||
style={
|
||||
{
|
||||
'--service-dot-color': effectiveColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Span name + service name */}
|
||||
<span className={styles.treeLabel}>
|
||||
@@ -696,19 +680,19 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
setSelectedSpanToAddToFunnel(span);
|
||||
}, []);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const handleSpanClick = useCallback(
|
||||
(span: SpanV3): void => {
|
||||
setSelectedSpan(span);
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
if (span?.span_id) {
|
||||
params.set('spanId', span?.span_id);
|
||||
urlQuery.set('spanId', span?.span_id);
|
||||
}
|
||||
|
||||
safeNavigate({ search: params.toString() }, { replace: true });
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
},
|
||||
[setSelectedSpan, safeNavigate],
|
||||
[setSelectedSpan, urlQuery, safeNavigate],
|
||||
);
|
||||
|
||||
// Left side columns using TanStack React Table (extensible for future columns)
|
||||
@@ -987,7 +971,9 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
<SpanHintBadge span={span} />
|
||||
{span.response_status_code && (
|
||||
<HttpStatusBadge statusCode={span.response_status_code} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import {
|
||||
__resetSearchParamsGetter,
|
||||
__setSearchParamsGetterForTest,
|
||||
} from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
// Local identity-proxy mock for this module so `styles.foo` resolves to
|
||||
// `'foo'` in test assertions. The global `__mocks__/cssMock.ts` stays as
|
||||
@@ -230,11 +226,6 @@ describe('Span Click User Flows', () => {
|
||||
jest.clearAllMocks();
|
||||
// Clear all URL parameters
|
||||
Array.from(mockUrlQuery.keys()).forEach((key) => mockUrlQuery.delete(key));
|
||||
__setSearchParamsGetterForTest(() => new URLSearchParams(mockUrlQuery));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetSearchParamsGetter();
|
||||
});
|
||||
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
@@ -269,6 +260,7 @@ describe('Span Click User Flows', () => {
|
||||
) as HTMLElement;
|
||||
await user.click(spanElement);
|
||||
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringContaining('spanId=span-1'),
|
||||
@@ -436,6 +428,10 @@ describe('Span Click User Flows', () => {
|
||||
await user.click(spanElement);
|
||||
|
||||
// Verify existing parameters are preserved and spanId is added
|
||||
expect(mockUrlQuery.get('existingParam')).toBe('existingValue');
|
||||
expect(mockUrlQuery.get('anotherParam')).toBe('anotherValue');
|
||||
expect(mockUrlQuery.get('spanId')).toBe('span-1');
|
||||
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
search: expect.stringMatching(
|
||||
|
||||
@@ -53,11 +53,3 @@ export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
|
||||
);
|
||||
|
||||
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
||||
|
||||
export enum TraceDetailsTab {
|
||||
Overview = 'overview',
|
||||
Thread = 'thread',
|
||||
}
|
||||
|
||||
export const LLM_OBSERVABILITY_DOCS_URL =
|
||||
'https://signoz.io/docs/llm-observability/';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
|
||||
|
||||
/** Span details position, persisted under the same key Overview uses. */
|
||||
export function useSpanDetailVariant(): [
|
||||
SpanDetailVariant,
|
||||
(variant: SpanDetailVariant) => void,
|
||||
] {
|
||||
const [variant, setVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const changeVariant = useCallback((next: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION, next);
|
||||
setVariant(next);
|
||||
}, []);
|
||||
|
||||
return [variant, changeVariant];
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { parseAsStringEnum, useQueryState, UseQueryStateReturn } from 'nuqs';
|
||||
|
||||
import { TraceDetailsTab } from '../constants';
|
||||
|
||||
export function useTraceDetailsTab(): UseQueryStateReturn<
|
||||
TraceDetailsTab,
|
||||
TraceDetailsTab
|
||||
> {
|
||||
return useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum(Object.values(TraceDetailsTab)).withDefault(
|
||||
TraceDetailsTab.Overview,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,87 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
import { useDetailsPanel } from 'components/DetailsPanel';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
|
||||
import { useTraceDetailsStripInfo } from './useTraceDetailsStripInfo';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceDetailsTab } from './constants';
|
||||
import { useTraceDetailsTab } from './hooks/useTraceDetailsTab';
|
||||
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
|
||||
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
|
||||
import NoData from './NoData/NoData';
|
||||
import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import TraceDetailsOverview from './TraceDetailsOverview/TraceDetailsOverview';
|
||||
import TraceDetailsThread from './TraceDetailsThread/TraceDetailsThread';
|
||||
import TraceDetailsWrapper from './TraceDetailsWrapper/TraceDetailsWrapper';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
||||
import { getAvailableColorByFieldNames } from './utils';
|
||||
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './TraceDetailsV3.module.scss';
|
||||
|
||||
// Lucide chevrons for the flame/waterfall accordion headers, matching the
|
||||
// span-tree chevrons in the waterfall.
|
||||
function renderPanelExpandIcon({
|
||||
isActive,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
}): JSX.Element {
|
||||
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
|
||||
}
|
||||
|
||||
function TraceDetailsV3(): JSX.Element {
|
||||
const [tab] = useTraceDetailsTab();
|
||||
const isOverview = tab === TraceDetailsTab.Overview;
|
||||
const { id: traceId } = useParams<TraceDetailV3URLProps>();
|
||||
const urlQuery = useUrlQuery();
|
||||
const [interestedSpanId, setInterestedSpanId] = useState<IInterestedSpan>(
|
||||
() => ({
|
||||
spanId: urlQuery.get('spanId') || '',
|
||||
isUncollapsed: urlQuery.get('spanId') !== '',
|
||||
}),
|
||||
);
|
||||
const [uncollapsedNodes, setUncollapsedNodes] = useState<string[]>([]);
|
||||
const [localUncollapsedNodes, setLocalUncollapsedNodes] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedSpan, setSelectedSpan] = useState<SpanV3>();
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [isFilterActive, setIsFilterActive] = useState(false);
|
||||
const [hasTraceData, setHasTraceData] = useState(false);
|
||||
|
||||
const selectedSpanId = urlQuery.get('spanId') || undefined;
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
|
||||
// Tracks which traceId the load event already fired for, so navigating
|
||||
// between traces (the route component stays mounted) re-fires it once each.
|
||||
const dataLoadedFiredForRef = useRef('');
|
||||
const colorByField = useTraceStore((s) => s.colorByField);
|
||||
const previewFieldsCount = useTraceStore((s) => s.previewFields.length);
|
||||
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
|
||||
|
||||
const handleSpanDetailsClose = useCallback((): void => {
|
||||
urlQuery.delete('spanId');
|
||||
safeNavigate({ search: urlQuery.toString() }, { replace: true });
|
||||
}, [urlQuery, safeNavigate]);
|
||||
|
||||
const handleFilteredSpansChange = useCallback(
|
||||
(spanIds: string[], isActive: boolean): void => {
|
||||
@@ -22,30 +91,421 @@ function TraceDetailsV3(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
// The filter query lives in Filters' local state, which unmounts with the
|
||||
// Overview tab; drop its result too so both start empty on return.
|
||||
const panelState = useDetailsPanel({
|
||||
entityId: selectedSpanId,
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
const deepLinkResolvedRef = useRef(false);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
if (!isOverview) {
|
||||
handleFilteredSpansChange([], false);
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
}, [isOverview, handleFilteredSpansChange]);
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
}, [urlQuery]);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
const frozenParamsRef = useRef({
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
? frozenParamsRef.current
|
||||
: {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
|
||||
const {
|
||||
data: traceData,
|
||||
isFetching: isFetchingTraceData,
|
||||
error: errorFetchingTraceData,
|
||||
} = useGetTraceV4({
|
||||
traceId,
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
const totalSpansCount = traceData?.payload?.totalSpansCount || 0;
|
||||
const totalErrorSpansCount = traceData?.payload?.totalErrorSpansCount || 0;
|
||||
|
||||
useTraceDetailsStripInfo({ totalSpansCount, totalErrorSpansCount });
|
||||
|
||||
const isFullDataLoaded =
|
||||
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
|
||||
|
||||
// Color-by options, gated on fields in loaded spans. Resource attrs are
|
||||
// trace-wide, so any window has the full set — no need to accumulate.
|
||||
const availableColorByFields = useMemo(() => {
|
||||
const spans = traceData?.payload?.spans;
|
||||
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
|
||||
}, [traceData?.payload?.spans]);
|
||||
|
||||
// Lock the ref once we confirm all data is loaded
|
||||
if (isFullDataLoaded && !fullDataLoadedRef.current) {
|
||||
fullDataLoadedRef.current = true;
|
||||
frozenParamsRef.current = {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!traceId ||
|
||||
dataLoadedFiredForRef.current === traceId ||
|
||||
!userPrefsReady
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const payload = traceData?.payload;
|
||||
if (!payload?.spans?.length) {
|
||||
return;
|
||||
}
|
||||
dataLoadedFiredForRef.current = traceId;
|
||||
const numServices = new Set(payload.spans.map((s) => s['service.name'])).size;
|
||||
logTraceEvent(TraceDetailEvents.DataLoaded, {
|
||||
[TraceDetailEventKeys.TotalSpansCount]: totalSpansCount,
|
||||
[TraceDetailEventKeys.NumServices]: numServices,
|
||||
[TraceDetailEventKeys.TraceDurationMs]:
|
||||
payload.endTimestampMillis - payload.startTimestampMillis,
|
||||
[TraceDetailEventKeys.HadErrors]: (payload.totalErrorSpansCount || 0) > 0,
|
||||
[TraceDetailEventKeys.FlamegraphSampled]:
|
||||
totalSpansCount > FLAMEGRAPH_SPAN_LIMIT,
|
||||
[TraceDetailEventKeys.SpanPanelVariant]:
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION) ||
|
||||
SpanDetailVariant.DOCKED_RIGHT,
|
||||
[TraceDetailEventKeys.ColorByField]: colorByField.name,
|
||||
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
|
||||
});
|
||||
}, [
|
||||
traceId,
|
||||
userPrefsReady,
|
||||
traceData,
|
||||
totalSpansCount,
|
||||
colorByField,
|
||||
previewFieldsCount,
|
||||
logTraceEvent,
|
||||
]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
const parentIds = new Set(
|
||||
allSpans.filter((s) => s.has_children).map((s) => s.span_id),
|
||||
);
|
||||
setLocalUncollapsedNodes(parentIds);
|
||||
}
|
||||
}, [isFullDataLoaded, allSpans]);
|
||||
|
||||
// Backend mode: sync uncollapsed state from API response
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFullDataLoaded &&
|
||||
traceData &&
|
||||
traceData.payload &&
|
||||
traceData.payload.uncollapsedSpans
|
||||
) {
|
||||
setUncollapsedNodes(traceData.payload.uncollapsedSpans);
|
||||
}
|
||||
}, [traceData, isFullDataLoaded]);
|
||||
|
||||
// Tracks whether we've already done the initial URL→selectedSpan handoff
|
||||
//Lets `interestedSpanId` stay purely as the refetch trigger in frontend mode.
|
||||
useEffect(() => {
|
||||
if (deepLinkResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedSpanId) {
|
||||
const span = allSpans.find((s) => s.span_id === selectedSpanId);
|
||||
if (!span) {
|
||||
// Span not in the current window — wait for more data (backend
|
||||
// pagination) before marking resolved.
|
||||
return;
|
||||
}
|
||||
setSelectedSpan(span);
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? allSpans[0]);
|
||||
}
|
||||
deepLinkResolvedRef.current = true;
|
||||
}, [selectedSpanId, allSpans]);
|
||||
|
||||
// Frontend mode: auto-expand ancestors of the URL-targeted span so it's
|
||||
// visible. Keyed on URL `spanId`(selectedSpanId).
|
||||
useEffect(() => {
|
||||
if (!isFullDataLoaded || !selectedSpanId || allSpans.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ancestors = getAncestorSpanIds(allSpans, selectedSpanId);
|
||||
if (ancestors.size === 0) {
|
||||
return;
|
||||
}
|
||||
setLocalUncollapsedNodes((prev) => {
|
||||
// Check if all ancestors are already expanded — avoid unnecessary state update
|
||||
let allPresent = true;
|
||||
for (const id of ancestors) {
|
||||
if (!prev.has(id)) {
|
||||
allPresent = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allPresent) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Set(prev);
|
||||
for (const id of ancestors) {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [isFullDataLoaded, selectedSpanId, allSpans]);
|
||||
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(['flame', 'waterfall']);
|
||||
|
||||
const handleCollapseChange = (key: string): void => {
|
||||
logTraceEvent(
|
||||
key === 'flame'
|
||||
? TraceDetailEvents.FlameGraphToggled
|
||||
: TraceDetailEvents.WaterfallToggled,
|
||||
{ [TraceDetailEventKeys.Expanded]: !activeKeys.includes(key) },
|
||||
);
|
||||
setActiveKeys((prev) =>
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
const [spanDetailVariant, setSpanDetailVariant] = useState<SpanDetailVariant>(
|
||||
() =>
|
||||
(getLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
) as SpanDetailVariant) || SpanDetailVariant.DOCKED_RIGHT,
|
||||
);
|
||||
|
||||
const RIGHT_DOCK_MIN = 480;
|
||||
const RIGHT_DOCK_MAX = 720;
|
||||
const [rightDockWidth, setRightDockWidth] = useState(RIGHT_DOCK_MIN);
|
||||
|
||||
const handleVariantChange = useCallback(
|
||||
(newVariant: SpanDetailVariant): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.TRACE_DETAILS_SPAN_DETAILS_POSITION,
|
||||
newVariant,
|
||||
);
|
||||
setSpanDetailVariant(newVariant);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
|
||||
const isDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
|
||||
const isRightDocked = spanDetailVariant === SpanDetailVariant.DOCKED_RIGHT;
|
||||
const isWaterfallDocked = panelState.isOpen && isDocked;
|
||||
const showRightDock = panelState.isOpen && isRightDocked;
|
||||
|
||||
const waterfallChildren = (
|
||||
<ResizableBox
|
||||
defaultHeight={300}
|
||||
minHeight={150}
|
||||
disabled={!isWaterfallDocked}
|
||||
>
|
||||
<TraceWaterfall
|
||||
traceData={traceData}
|
||||
isFetchingTraceData={isFetchingTraceData}
|
||||
errorFetchingTraceData={errorFetchingTraceData}
|
||||
traceId={traceId || ''}
|
||||
interestedSpanId={interestedSpanId}
|
||||
setInterestedSpanId={setInterestedSpanId}
|
||||
uncollapsedNodes={uncollapsedNodes}
|
||||
isFullDataLoaded={isFullDataLoaded}
|
||||
localUncollapsedNodes={localUncollapsedNodes}
|
||||
setLocalUncollapsedNodes={setLocalUncollapsedNodes}
|
||||
selectedSpan={selectedSpan}
|
||||
setSelectedSpan={setSelectedSpan}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
);
|
||||
|
||||
return (
|
||||
<TraceDetailsWrapper>
|
||||
<TraceDetailsHeader
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
showTraceDetailsHeaderOptions={hasTraceData}
|
||||
/>
|
||||
{isOverview ? (
|
||||
<TraceDetailsOverview
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
onHasTraceDataChange={setHasTraceData}
|
||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
||||
<div className={styles.root}>
|
||||
<TraceDetailsHeader
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
showTraceDetailsHeaderOptions={
|
||||
!!traceData?.payload?.spans?.length && !showNoData
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TraceDetailsThread />
|
||||
)}
|
||||
</TraceDetailsWrapper>
|
||||
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.layoutRow}>
|
||||
<div className={styles.content}>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className={styles.collapseLabel}>
|
||||
<span className={styles.collapseTitle}>
|
||||
Flame Graph
|
||||
{traceData?.payload?.totalSpansCount &&
|
||||
traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Info
|
||||
size={16}
|
||||
color="var(--l2-foreground)"
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</WarningPopover>
|
||||
)}
|
||||
</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className={styles.collapseCount}>
|
||||
<span className={styles.collapseCountItem}>
|
||||
<ChartNoAxesGantt size={13} />
|
||||
Spans: {traceData.payload.totalSpansCount}
|
||||
</span>
|
||||
<span
|
||||
className={cx(styles.collapseCountItem, {
|
||||
[styles.hasErrors]: traceData.payload.totalErrorSpansCount > 0,
|
||||
})}
|
||||
>
|
||||
<TriangleAlert size={13} />
|
||||
Errors:{' '}
|
||||
{traceData.payload.totalErrorSpansCount ?? (
|
||||
<span className="translate-safe">{0}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
selectedSpan={selectedSpan}
|
||||
totalSpansCount={totalSpansCount}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={cx(styles.waterfallCollapse, {
|
||||
[styles.isDocked]: isWaterfallDocked,
|
||||
})}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall')
|
||||
? waterfallChildren
|
||||
: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className={styles.dockedSpanDetails}>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRightDock && (
|
||||
<ResizableBox
|
||||
handle="left"
|
||||
defaultWidth={rightDockWidth}
|
||||
minWidth={RIGHT_DOCK_MIN}
|
||||
maxWidth={RIGHT_DOCK_MAX}
|
||||
onResize={setRightDockWidth}
|
||||
className={styles.rightDock}
|
||||
>
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED_RIGHT}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && spanDetailVariant === SpanDetailVariant.DIALOG && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TraceStoreSync>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
.tooltipBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
.tooltipTitle {
|
||||
font-family: 'SF Mono', 'Geist Mono', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.tooltipSurface {
|
||||
background-color: color-mix(in srgb, var(--l3-background) 60%, transparent);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--spacing-4);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
--tooltip-background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
);
|
||||
--tooltip-border-color: var(--l2-border);
|
||||
--tooltip-border-radius: var(--radius-2);
|
||||
--tooltip-padding: var(--spacing-4);
|
||||
--tooltip-box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
/* Above the docked span details FloatingPanel (z-index 999). */
|
||||
--tooltip-z-index: 1000;
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
@@ -4,28 +4,36 @@ import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import {
|
||||
setTraceStoreAvailableColorByFields,
|
||||
setTraceStoreCallbacks,
|
||||
setTraceStoreUserPreferences,
|
||||
} from './traceStore';
|
||||
|
||||
interface TraceStoreSyncProps {
|
||||
availableColorByFields: string[] | undefined;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges React-managed inputs (`userPreferences` from AppContext and the
|
||||
* user-pref mutation hook) into the Zustand store. `availableColorByFields`
|
||||
* is pushed by TraceDetailsOverview, which owns the span data.
|
||||
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
|
||||
* from AppContext, and the user-pref mutation hook) into the Zustand store.
|
||||
*
|
||||
* Renders nothing until `userPreferences` resolves so the flamegraph never
|
||||
* paints with the default colour first and then swaps to the user's
|
||||
* persisted choice. AppProvider fires the prefs query as soon as the user
|
||||
* is logged in, so this gate is usually already settled by mount time.
|
||||
*/
|
||||
function TraceStoreSync({ children }: TraceStoreSyncProps): JSX.Element | null {
|
||||
function TraceStoreSync({
|
||||
availableColorByFields,
|
||||
children,
|
||||
}: TraceStoreSyncProps): JSX.Element | null {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreAvailableColorByFields(availableColorByFields);
|
||||
}, [availableColorByFields]);
|
||||
|
||||
useEffect(() => {
|
||||
setTraceStoreUserPreferences(userPreferences ?? null);
|
||||
}, [userPreferences]);
|
||||
|
||||
@@ -37,7 +37,7 @@ interface TraceStoreState {
|
||||
availableColorByOptions: ColorByOption[];
|
||||
previewFields: TelemetryFieldKey[];
|
||||
|
||||
// --- Setters used only by TraceStoreSync and TraceDetailsOverview ---
|
||||
// --- Setters used only by TraceStoreSync ---
|
||||
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
|
||||
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
|
||||
setCallbacks: (callbacks: {
|
||||
|
||||
@@ -46,8 +46,6 @@ import {
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
TRACE_SCENARIOS,
|
||||
type TraceScenario,
|
||||
traceWaterfallResponse,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
|
||||
@@ -105,13 +103,6 @@ const spanLogScopeOf = (body: QueryRangeRequestV5): SpanLogScope => {
|
||||
|
||||
export const traceDetailsMocks = defineStoryMocks({
|
||||
controls: {
|
||||
scenario: choiceControl<TraceScenario>('Scenario', {
|
||||
group: TRACE,
|
||||
description:
|
||||
'The spans the trace is built from: a checkout, one span per HTTP status badge, or an agent run with LLM, tool and agent spans and the AI totals in the header.',
|
||||
options: TRACE_SCENARIOS,
|
||||
value: 'checkout',
|
||||
}),
|
||||
spans: countControl('Spans', {
|
||||
group: TRACE,
|
||||
description:
|
||||
@@ -160,7 +151,6 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
handlers: (values, response) => {
|
||||
const trace = {
|
||||
scenario: values.scenario,
|
||||
spans: values.spans,
|
||||
errors: values.errors,
|
||||
traceStart: traceStartOf(),
|
||||
@@ -298,7 +288,7 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
},
|
||||
config: (values) => ({
|
||||
route: values.spanPanel
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.scenario, values.spans))}`
|
||||
? `/trace/${STORY_TRACE_ID}?spanId=${spanIdAt(focusSpanIndex(values.spans))}`
|
||||
: `/trace/${STORY_TRACE_ID}`,
|
||||
}),
|
||||
effect: (values) => {
|
||||
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import {
|
||||
focusSpanIndex,
|
||||
scenarioSpanCount,
|
||||
spanIdAt,
|
||||
STORY_TRACE_ID,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
import { STORY_TRACE_ID } from './__story_mockdata__/traceDetails';
|
||||
import {
|
||||
openRowMenu,
|
||||
startDownload,
|
||||
@@ -101,40 +96,6 @@ export const BottomStripLoading: Story = {
|
||||
args: { bottomStrip: true, dataState: 'loading' },
|
||||
};
|
||||
|
||||
/**
|
||||
* One span per HTTP status badge the waterfall draws: informational, success,
|
||||
* redirect, client and server errors, and a span with no status code and so no
|
||||
* badge.
|
||||
*/
|
||||
export const StatusCodes: Story = {
|
||||
args: {
|
||||
scenario: 'status-codes',
|
||||
spans: scenarioSpanCount('status-codes'),
|
||||
spanPanel: false,
|
||||
},
|
||||
};
|
||||
|
||||
const AI_SPANS = scenarioSpanCount('ai');
|
||||
|
||||
/**
|
||||
* An agent run: LLM, tool and agent badges with sparkle icons in the waterfall,
|
||||
* token and cost totals in the header, the span panel on an LLM call with its
|
||||
* tokens and cost, and that span's hover card open on its usage breakdown.
|
||||
*/
|
||||
export const AiTrace: Story = {
|
||||
args: { scenario: 'ai', spans: AI_SPANS },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const row = await within(canvasElement).findByTestId(
|
||||
`cell-0-${spanIdAt(focusSpanIndex('ai', AI_SPANS))}`,
|
||||
undefined,
|
||||
untilLoaded,
|
||||
);
|
||||
|
||||
await fireEvent.mouseOver(row);
|
||||
await screen.findByText('Usage Breakdown', undefined, untilLoaded);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns on Highlight errors, which writes `has_error = true` into the filter and
|
||||
* expands the bar to show it.
|
||||
|
||||
@@ -23,10 +23,6 @@ interface SpanTemplate {
|
||||
name: string;
|
||||
kind: string;
|
||||
failing?: boolean;
|
||||
/** The span the panel opens on when nothing fails. */
|
||||
focus?: boolean;
|
||||
/** `response_status_code`; failing spans default to 503, the rest to 200. */
|
||||
statusCode?: string;
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
@@ -127,145 +123,6 @@ const SPAN_TEMPLATES: SpanTemplate[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** One span per badge colour, plus a span with no status code and so no badge. */
|
||||
const STATUS_CODE_TEMPLATES: SpanTemplate[] = [
|
||||
['200', 'GET /status/ok'],
|
||||
['101', 'GET /ws/upgrade'],
|
||||
['201', 'POST /status/created'],
|
||||
['204', 'DELETE /status/no-content'],
|
||||
['301', 'GET /status/moved'],
|
||||
['304', 'GET /status/not-modified'],
|
||||
['400', 'POST /status/bad-request'],
|
||||
['401', 'GET /status/unauthorized'],
|
||||
['404', 'GET /status/not-found'],
|
||||
['429', 'GET /status/rate-limited'],
|
||||
['500', 'GET /status/internal-error'],
|
||||
['502', 'GET /status/bad-gateway'],
|
||||
['503', 'GET /status/unavailable'],
|
||||
['504', 'GET /status/timeout'],
|
||||
['', 'internal.compute'],
|
||||
].map(
|
||||
([statusCode, name]): SpanTemplate => ({
|
||||
service: 'status-api',
|
||||
name,
|
||||
kind: statusCode ? 'Server' : 'Internal',
|
||||
failing: Number(statusCode) >= 500,
|
||||
statusCode,
|
||||
attributes: statusCode
|
||||
? {
|
||||
'http.method': name.split(' ')[0],
|
||||
'http.status_code': Number(statusCode),
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* An agent run: agent, LLM and tool spans, each badged by kind. The LLM spans
|
||||
* cover cache counted inside input (OpenAI), cache counted beside it
|
||||
* (Anthropic), reasoning, and an embedding with input only.
|
||||
*/
|
||||
const AI_TEMPLATES: SpanTemplate[] = [
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'invoke_agent support-agent',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.agent.name': 'support-agent' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat gpt-4o',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'gpt-4o-2024-08-06',
|
||||
'gen_ai.usage.input_tokens': 1840,
|
||||
'gen_ai.usage.output_tokens': 212,
|
||||
'gen_ai.usage.cache_read.input_tokens': 1024,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 0,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00566,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'execute_tool search_docs',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.tool.name': 'search_docs' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat claude-sonnet-4',
|
||||
kind: 'Client',
|
||||
focus: true,
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'claude-sonnet-4-20250514',
|
||||
'gen_ai.usage.input_tokens': 86,
|
||||
'gen_ai.usage.output_tokens': 640,
|
||||
'gen_ai.usage.cache_read.input_tokens': 4200,
|
||||
'gen_ai.usage.cache_creation.input_tokens': 1300,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.01853,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'docs-api',
|
||||
name: 'GET /docs/search',
|
||||
kind: 'Server',
|
||||
attributes: {
|
||||
'http.method': 'GET',
|
||||
'http.route': '/docs/search',
|
||||
'http.status_code': 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'execute_tool fetch_ticket',
|
||||
kind: 'Internal',
|
||||
failing: true,
|
||||
attributes: { 'gen_ai.tool.name': 'fetch_ticket' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'invoke_agent triage-agent',
|
||||
kind: 'Internal',
|
||||
attributes: { 'gen_ai.agent.name': 'triage-agent' },
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'embeddings text-embedding-3-small',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'text-embedding-3-small',
|
||||
'gen_ai.usage.input_tokens': 312,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.00000624,
|
||||
},
|
||||
},
|
||||
{
|
||||
service: 'support-agent',
|
||||
name: 'chat o3-mini',
|
||||
kind: 'Client',
|
||||
attributes: {
|
||||
'gen_ai.request.model': 'o3-mini-2025-01-31',
|
||||
'gen_ai.usage.input_tokens': 920,
|
||||
'gen_ai.usage.output_tokens': 1480,
|
||||
'gen_ai.usage.reasoning.output_tokens': 1152,
|
||||
'signoz.gen_ai.usage.tokens.cost': 0.007524,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const TRACE_SCENARIOS = ['checkout', 'status-codes', 'ai'] as const;
|
||||
|
||||
export type TraceScenario = (typeof TRACE_SCENARIOS)[number];
|
||||
|
||||
const SCENARIO_TEMPLATES: Record<TraceScenario, SpanTemplate[]> = {
|
||||
checkout: SPAN_TEMPLATES,
|
||||
'status-codes': STATUS_CODE_TEMPLATES,
|
||||
ai: AI_TEMPLATES,
|
||||
};
|
||||
|
||||
/** Spans it takes for the scenario to show every template once. */
|
||||
export const scenarioSpanCount = (scenario: TraceScenario): number =>
|
||||
SCENARIO_TEMPLATES[scenario].length;
|
||||
|
||||
const TRACE_SERVICES = [
|
||||
...new Set(SPAN_TEMPLATES.map(({ service }) => service)),
|
||||
];
|
||||
@@ -289,7 +146,6 @@ interface BuiltSpan {
|
||||
}
|
||||
|
||||
interface TraceOptions {
|
||||
scenario: TraceScenario;
|
||||
spans: number;
|
||||
errors: boolean;
|
||||
/** Epoch milliseconds the root span starts at. */
|
||||
@@ -300,33 +156,24 @@ export const spanIdAt = (index: number): string =>
|
||||
`b17e${index.toString(16).padStart(12, '0')}`;
|
||||
|
||||
/**
|
||||
* The span the story opens the panel on: the scenario's focus span, else the
|
||||
* first failing one, so its events and error styling are on screen, and the
|
||||
* root when neither is in the trace.
|
||||
* The span the story opens the panel on: the first failing one, so its events
|
||||
* and error styling are on screen, and the root when nothing fails.
|
||||
*/
|
||||
export const focusSpanIndex = (
|
||||
scenario: TraceScenario,
|
||||
spans: number,
|
||||
): number => {
|
||||
const templates = SCENARIO_TEMPLATES[scenario];
|
||||
const focus = templates.findIndex((template) => template.focus);
|
||||
const failing = templates.findIndex((template) => template.failing);
|
||||
const index = focus >= 0 ? focus : failing;
|
||||
export const focusSpanIndex = (spans: number): number => {
|
||||
const failing = SPAN_TEMPLATES.findIndex(({ failing }) => failing);
|
||||
|
||||
return index >= 0 && index < spans ? index : 0;
|
||||
return failing >= 0 && failing < spans ? failing : 0;
|
||||
};
|
||||
|
||||
const buildSpans = ({
|
||||
scenario,
|
||||
spans,
|
||||
errors,
|
||||
traceStart,
|
||||
}: TraceOptions): BuiltSpan[] => {
|
||||
const built: BuiltSpan[] = [];
|
||||
const templates = SCENARIO_TEMPLATES[scenario];
|
||||
|
||||
for (let index = 0; index < spans; index += 1) {
|
||||
const template = templates[index % templates.length];
|
||||
const template = SPAN_TEMPLATES[index % SPAN_TEMPLATES.length];
|
||||
const parent = index === 0 ? undefined : built[parentIndexOf(index)];
|
||||
// Children fit inside their parent's window, second child after the first,
|
||||
// so no span outlives its parent and no two siblings overlap.
|
||||
@@ -410,8 +257,7 @@ const waterfallSpan = (span: BuiltSpan): SpantypesWaterfallSpanDTO => ({
|
||||
status_code: span.hasError ? 2 : 1,
|
||||
status_code_string: span.hasError ? 'Error' : 'Ok',
|
||||
status_message: span.hasError ? 'upstream unavailable' : '',
|
||||
response_status_code:
|
||||
span.template.statusCode ?? (span.hasError ? '503' : '200'),
|
||||
response_status_code: span.hasError ? '503' : '200',
|
||||
has_children: span.hasChildren,
|
||||
sub_tree_node_count: span.subTreeNodeCount,
|
||||
level: span.level,
|
||||
@@ -458,36 +304,6 @@ export const traceWaterfallResponse = (
|
||||
};
|
||||
};
|
||||
|
||||
const numberAttribute = (span: BuiltSpan, key: string): number =>
|
||||
Number(span.template.attributes[key] ?? 0);
|
||||
|
||||
/** The trace's AI totals, summed over its spans the way the backend does. */
|
||||
const traceAiSummary = (
|
||||
spans: BuiltSpan[],
|
||||
): GetTraceSummary200['data']['ai'] => {
|
||||
const aiSpans = spans.filter(({ template }) =>
|
||||
Object.keys(template.attributes).some((key) => key.includes('gen_ai.')),
|
||||
);
|
||||
|
||||
if (aiSpans.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sum = (key: string): number =>
|
||||
aiSpans.reduce((total, span) => total + numberAttribute(span, key), 0);
|
||||
|
||||
return {
|
||||
tokens: {
|
||||
input: sum('gen_ai.usage.input_tokens'),
|
||||
output: sum('gen_ai.usage.output_tokens'),
|
||||
cacheRead: sum('gen_ai.usage.cache_read.input_tokens'),
|
||||
cacheWrite: sum('gen_ai.usage.cache_creation.input_tokens'),
|
||||
reasoning: sum('gen_ai.usage.reasoning.output_tokens'),
|
||||
},
|
||||
totalCost: sum('signoz.gen_ai.usage.tokens.cost'),
|
||||
};
|
||||
};
|
||||
|
||||
export const traceSummaryResponse = (
|
||||
options: TraceOptions & { missingSpans: boolean },
|
||||
): GetTraceSummary200 => {
|
||||
@@ -505,7 +321,6 @@ export const traceSummaryResponse = (
|
||||
totalSpansCount: spans.length,
|
||||
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
|
||||
hasMissingSpans: options.missingSpans,
|
||||
ai: traceAiSummary(spans),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -657,7 +472,7 @@ export const spanLogRows = (
|
||||
): RawRow[] => {
|
||||
const step = ROOT_DURATION_MS / Math.max(count, 1);
|
||||
const spans = buildSpans(options);
|
||||
const focus = spans[focusSpanIndex(options.scenario, options.spans)];
|
||||
const focus = spans[focusSpanIndex(options.spans)];
|
||||
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const at = options.traceStart + SCOPE_OFFSET_MS[scope] + index * step;
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import {
|
||||
AiSpanKind,
|
||||
getAiSpanKind,
|
||||
getTotalInputTokens,
|
||||
isAiSpan,
|
||||
} from '../aiTraceUtils';
|
||||
|
||||
const spanWith = (attributes: Record<string, string | number>): SpanV3 =>
|
||||
({ attributes, resource: {} }) as unknown as SpanV3;
|
||||
|
||||
describe('getAiSpanKind', () => {
|
||||
it.each<[Record<string, string | number>, AiSpanKind]>([
|
||||
[{ 'gen_ai.request.model': 'gpt-4o' }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.input_tokens': 0 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.usage.reasoning.output_tokens': 12 }, AiSpanKind.Llm],
|
||||
[{ 'gen_ai.tool.name': 'search' }, AiSpanKind.Tool],
|
||||
[{ 'gen_ai.agent.name': 'planner' }, AiSpanKind.Agent],
|
||||
[
|
||||
{ 'gen_ai.tool.name': 'search', 'gen_ai.request.model': 'gpt-4o' },
|
||||
AiSpanKind.Tool,
|
||||
],
|
||||
[
|
||||
{ 'gen_ai.agent.name': 'planner', 'gen_ai.usage.input_tokens': 10 },
|
||||
AiSpanKind.Agent,
|
||||
],
|
||||
])('classifies %p as %s', (attributes, kind) => {
|
||||
expect(getAiSpanKind(spanWith(attributes))).toBe(kind);
|
||||
});
|
||||
|
||||
it('returns undefined for non-AI spans and empty values', () => {
|
||||
expect(getAiSpanKind(spanWith({ 'http.method': 'GET' }))).toBeUndefined();
|
||||
expect(getAiSpanKind(spanWith({ 'gen_ai.tool.name': '' }))).toBeUndefined();
|
||||
expect(isAiSpan(spanWith({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalInputTokens', () => {
|
||||
it.each([
|
||||
// Cache reported inside input (OpenAI): input already includes it.
|
||||
[{ input: 1000, cacheRead: 600, cacheWrite: 100 }, 1000],
|
||||
// Cache reported separately (Anthropic): add it.
|
||||
[{ input: 50, cacheRead: 600, cacheWrite: 100 }, 750],
|
||||
[{ input: 71, cacheRead: 0, cacheWrite: 0 }, 71],
|
||||
[{ input: 10 }, 10],
|
||||
[{ cacheRead: 600 }, 600],
|
||||
])('%p → %p', (tokens, expected) => {
|
||||
expect(getTotalInputTokens(tokens)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
export const GEN_AI_KEYS = {
|
||||
requestModel: 'gen_ai.request.model',
|
||||
toolName: 'gen_ai.tool.name',
|
||||
agentName: 'gen_ai.agent.name',
|
||||
inputTokens: 'gen_ai.usage.input_tokens',
|
||||
outputTokens: 'gen_ai.usage.output_tokens',
|
||||
cacheReadTokens: 'gen_ai.usage.cache_read.input_tokens',
|
||||
cacheCreationTokens: 'gen_ai.usage.cache_creation.input_tokens',
|
||||
reasoningTokens: 'gen_ai.usage.reasoning.output_tokens',
|
||||
cost: 'signoz.gen_ai.usage.tokens.cost',
|
||||
inputMessages: 'gen_ai.input.messages',
|
||||
outputMessages: 'gen_ai.output.messages',
|
||||
} as const;
|
||||
|
||||
export enum AiSpanKind {
|
||||
Llm = 'llm',
|
||||
Tool = 'tool',
|
||||
Agent = 'agent',
|
||||
}
|
||||
|
||||
const LLM_KEYS = [
|
||||
GEN_AI_KEYS.requestModel,
|
||||
GEN_AI_KEYS.inputTokens,
|
||||
GEN_AI_KEYS.outputTokens,
|
||||
GEN_AI_KEYS.cacheReadTokens,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
GEN_AI_KEYS.reasoningTokens,
|
||||
];
|
||||
|
||||
/** Keeps numeric 0, which `getSpanAttribute`'s `||` chain drops. */
|
||||
export function getGenAiValue(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | number | boolean | undefined {
|
||||
const value = span.attributes?.[key] ?? span.resource?.[key];
|
||||
return value === '' ? undefined : value;
|
||||
}
|
||||
|
||||
function hasAttribute(span: SpanV3, key: string): boolean {
|
||||
return getGenAiValue(span, key) !== undefined;
|
||||
}
|
||||
|
||||
/** Tool and agent spans may also carry model or usage keys, so they win over LLM. */
|
||||
export function getAiSpanKind(span: SpanV3): AiSpanKind | undefined {
|
||||
if (hasAttribute(span, GEN_AI_KEYS.toolName)) {
|
||||
return AiSpanKind.Tool;
|
||||
}
|
||||
if (hasAttribute(span, GEN_AI_KEYS.agentName)) {
|
||||
return AiSpanKind.Agent;
|
||||
}
|
||||
if (LLM_KEYS.some((key) => hasAttribute(span, key))) {
|
||||
return AiSpanKind.Llm;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isAiSpan(span: SpanV3): boolean {
|
||||
return getAiSpanKind(span) !== undefined;
|
||||
}
|
||||
|
||||
/** Token counts; `null` or missing means the backend did not send the value. */
|
||||
export interface AiTokenCounts {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
reasoning?: number;
|
||||
}
|
||||
|
||||
export function hasValue(value: number | null | undefined): value is number {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input including cache read/write, counted once. Providers that report cache
|
||||
* inside `input` (OpenAI) have `input >= cache`; those that report it
|
||||
* separately (Anthropic) usually have `input < cache`.
|
||||
*/
|
||||
export function getTotalInputTokens({
|
||||
input,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
}: AiTokenCounts): number {
|
||||
const inputValue = input ?? 0;
|
||||
const cache = (cacheRead ?? 0) + (cacheWrite ?? 0);
|
||||
return inputValue < cache ? inputValue + cache : inputValue;
|
||||
}
|
||||
|
||||
/** `Input Token` for a count of 1, `Input Tokens` otherwise. */
|
||||
export function getTokenLabel(
|
||||
prefix: string,
|
||||
count: number | null | undefined,
|
||||
): string {
|
||||
return `${prefix} ${count === 1 ? 'Token' : 'Tokens'}`;
|
||||
}
|
||||
|
||||
/** Missing values render as `0`. */
|
||||
export function formatTokens(value: number | null | undefined): string {
|
||||
return hasValue(value) ? value.toLocaleString('en-US') : '0';
|
||||
}
|
||||
|
||||
export function formatCost(value: number): string {
|
||||
return `$ ${value.toLocaleString('en-US', { maximumFractionDigits: 10 })}`;
|
||||
}
|
||||
|
||||
export interface SpanAiUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
||||
export interface SpanAiDetails {
|
||||
model?: string;
|
||||
toolName?: string;
|
||||
agentName?: string;
|
||||
usage?: SpanAiUsage;
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsString(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
return value === undefined ? undefined : String(value);
|
||||
}
|
||||
|
||||
function getGenAiAttributeAsNumber(
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): number | undefined {
|
||||
const value = getGenAiValue(span, key);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function getAIUsageDetails(span: SpanV3): SpanAiDetails | undefined {
|
||||
const usage: SpanAiUsage = {
|
||||
inputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.inputTokens),
|
||||
outputTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.outputTokens),
|
||||
cacheReadTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cacheReadTokens),
|
||||
cacheCreationTokens: getGenAiAttributeAsNumber(
|
||||
span,
|
||||
GEN_AI_KEYS.cacheCreationTokens,
|
||||
),
|
||||
reasoningTokens: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.reasoningTokens),
|
||||
cost: getGenAiAttributeAsNumber(span, GEN_AI_KEYS.cost),
|
||||
};
|
||||
const hasUsage = Object.values(usage).some((value) => value !== undefined);
|
||||
|
||||
const details: SpanAiDetails = {
|
||||
model: getGenAiAttributeAsString(span, GEN_AI_KEYS.requestModel),
|
||||
toolName: getGenAiAttributeAsString(span, GEN_AI_KEYS.toolName),
|
||||
agentName: getGenAiAttributeAsString(span, GEN_AI_KEYS.agentName),
|
||||
usage: hasUsage ? usage : undefined,
|
||||
};
|
||||
|
||||
return details.model || details.toolName || details.agentName || hasUsage
|
||||
? details
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function convertToAiTokens(usage: SpanAiUsage): AiTokenCounts {
|
||||
return {
|
||||
input: usage.inputTokens,
|
||||
output: usage.outputTokens,
|
||||
cacheRead: usage.cacheReadTokens,
|
||||
cacheWrite: usage.cacheCreationTokens,
|
||||
reasoning: usage.reasoningTokens,
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
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. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.",
|
||||
Description: "Returns the spans carrying gen_ai input or output messages, or a tool execution, in timestamp order. Messages already in the OTel GenAI shape are decoded into formatted_input and formatted_output, with formatter naming the converter and formatter_warnings what it could not resolve. 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",
|
||||
|
||||
@@ -158,10 +158,6 @@ func (s *traceStore) GetTraceStats(ctx context.Context, orgID valuer.UUID, trace
|
||||
|
||||
// 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) {
|
||||
attributeKey := func(name string, dataType telemetrytypes.FieldDataType) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: dataType}
|
||||
}
|
||||
|
||||
values := []struct{ key, alias string }{
|
||||
{aiobservabilitytypes.GenAIUsageInputTokens, "input_tokens_value"},
|
||||
{aiobservabilitytypes.GenAIUsageOutputTokens, "output_tokens_value"},
|
||||
@@ -175,26 +171,17 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
|
||||
for _, value := range values {
|
||||
names = append(names, value.key)
|
||||
}
|
||||
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))
|
||||
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()))
|
||||
|
||||
gate := make([]string, 0, len(aiobservabilitytypes.GenAISpanGateKeys))
|
||||
for _, name := range aiobservabilitytypes.GenAISpanGateKeys {
|
||||
conds, _, err := querybuilder.Conditions(ctx, q, s.storage, attributeKey(name, telemetrytypes.FieldDataTypeString), qbtypes.FilterOperatorExists, nil, keys, false, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gate = append(gate, conds...)
|
||||
isGenAI, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := []string{sb.Or(gate...) + " AS is_gen_ai"}
|
||||
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
|
||||
@@ -310,7 +297,11 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
|
||||
sb.SelectMore("attributes")
|
||||
}
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
hasMessages, err := s.messagesExistCondition(ctx, q, orgID, bounds, sb)
|
||||
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAIThreadKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isThreadSpan, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAIThreadKeys, keys, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -318,7 +309,7 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", bounds.End.Unix()),
|
||||
hasMessages,
|
||||
isThreadSpan,
|
||||
)
|
||||
if cursor := page.Cursor; cursor != nil {
|
||||
// ClickHouse can't use an index for a tuple comparison, so the separate timestamp and
|
||||
@@ -355,38 +346,6 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
// messagesExistCondition resolves the gen_ai message keys through the attribute evolution metadata
|
||||
// and the use_trace_attributes_json flag, so the filter reads the same columns the query builder does.
|
||||
func (s *traceStore) messagesExistCondition(ctx context.Context, q qbtypes.QueryInfo, orgID valuer.UUID, bounds *spantypes.TraceBounds, sb *sqlbuilder.SelectBuilder) (string, error) {
|
||||
names := []string{aiobservabilitytypes.GenAIInputMessages, aiobservabilitytypes.GenAIOutputMessages}
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, len(names))
|
||||
for i, name := range names {
|
||||
selectors[i] = &telemetrytypes.FieldKeySelector{
|
||||
StartUnixMilli: bounds.Start.UnixMilli(),
|
||||
EndUnixMilli: bounds.End.UnixMilli(),
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
Name: name,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
}
|
||||
}
|
||||
fieldKeys, _, err := s.metadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "error fetching thread field keys")
|
||||
}
|
||||
|
||||
conds := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
key := &telemetrytypes.TelemetryFieldKey{Name: name, Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute}
|
||||
keyConds, _, err := querybuilder.Conditions(ctx, q, s.storage, key, qbtypes.FilterOperatorExists, nil, fieldKeys, false, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conds = append(conds, keyConds...)
|
||||
}
|
||||
return sb.Or(conds...), 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)")
|
||||
@@ -532,3 +491,34 @@ 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
|
||||
}
|
||||
|
||||
@@ -220,14 +220,14 @@ func TestGetThreadSpans(t *testing.T) {
|
||||
name: "FlagOff_ReadsAndFiltersLegacyMaps",
|
||||
jsonRelease: &jsonInsideTrace,
|
||||
selectSQL: selectSQL,
|
||||
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages'))",
|
||||
whereSQL: "(mapContains(attributes_string, 'gen_ai.input.messages') OR mapContains(attributes_string, 'gen_ai.output.messages') OR mapContains(attributes_string, 'gen_ai.tool.name'))",
|
||||
},
|
||||
{
|
||||
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
|
||||
jsonOn: true,
|
||||
jsonRelease: &jsonInsideTrace,
|
||||
selectSQL: selectSQL + ", attributes",
|
||||
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL)",
|
||||
whereSQL: "(multiIf(attributes.`gen_ai.input.messages` IS NOT NULL, attributes.`gen_ai.input.messages`::String, mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.output.messages` IS NOT NULL, attributes.`gen_ai.output.messages`::String, mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ const (
|
||||
// agent span. A trace belongs to the AI explorer when any span carries one.
|
||||
var GenAISpanGateKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
|
||||
|
||||
// GenAIThreadKeys mark a span as a thread row: a model call with its messages, or a tool execution.
|
||||
var GenAIThreadKeys = []string{GenAIInputMessages, GenAIOutputMessages, GenAIToolName}
|
||||
|
||||
// GenAISpanFilterExpression renders the gate as a query-builder filter
|
||||
// expression: each gate key ORed on EXISTS.
|
||||
func GenAISpanFilterExpression() string {
|
||||
|
||||
58
pkg/types/aiobservabilitytypes/message.go
Normal file
58
pkg/types/aiobservabilitytypes/message.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// The message types mirror the OpenTelemetry GenAI message schemas, one type per schema definition:
|
||||
// open-telemetry/semantic-conventions-genai@06ec68e, model/gen-ai/gen-ai-{input,output}-messages.json.
|
||||
// Keys the schema does not name are dropped; the raw attribute on the span still has them.
|
||||
|
||||
package aiobservabilitytypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
var (
|
||||
RoleSystem = Role{valuer.NewString("system")}
|
||||
RoleUser = Role{valuer.NewString("user")}
|
||||
RoleAssistant = Role{valuer.NewString("assistant")}
|
||||
RoleTool = Role{valuer.NewString("tool")}
|
||||
)
|
||||
|
||||
var (
|
||||
FinishReasonStop = FinishReason{valuer.NewString("stop")}
|
||||
FinishReasonLength = FinishReason{valuer.NewString("length")}
|
||||
FinishReasonContentFilter = FinishReason{valuer.NewString("content_filter")}
|
||||
FinishReasonToolCall = FinishReason{valuer.NewString("tool_call")}
|
||||
FinishReasonCompaction = FinishReason{valuer.NewString("compaction")}
|
||||
FinishReasonError = FinishReason{valuer.NewString("error")}
|
||||
)
|
||||
|
||||
var (
|
||||
ModalityImage = Modality{valuer.NewString("image")}
|
||||
ModalityVideo = Modality{valuer.NewString("video")}
|
||||
ModalityAudio = Modality{valuer.NewString("audio")}
|
||||
ModalityDocument = Modality{valuer.NewString("document")}
|
||||
)
|
||||
|
||||
// Role, FinishReason and Modality are open enums: the variables are the values the schema
|
||||
// names, any other string is kept as sent, so none of them lists an Enum for the spec.
|
||||
type Role struct{ valuer.String }
|
||||
|
||||
type FinishReason struct{ valuer.String }
|
||||
|
||||
type Modality struct{ valuer.String }
|
||||
|
||||
type InputMessages []ChatMessage
|
||||
|
||||
// OutputMessages holds one message per choice.
|
||||
type OutputMessages []OutputMessage
|
||||
|
||||
type ChatMessage struct {
|
||||
Role Role `json:"role" required:"true"`
|
||||
Parts Parts `json:"parts" required:"true" nullable:"false"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// OutputMessage is one choice. The schema deprecates FinishReason in favour of
|
||||
// gen_ai.response.finish_reasons; it stays so each choice carries its own.
|
||||
type OutputMessage struct {
|
||||
Role Role `json:"role" required:"true"`
|
||||
Parts Parts `json:"parts" required:"true" nullable:"false"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
FinishReason *FinishReason `json:"finish_reason,omitempty"`
|
||||
}
|
||||
239
pkg/types/aiobservabilitytypes/message_format.go
Normal file
239
pkg/types/aiobservabilitytypes/message_format.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatterSemconv = "semconv"
|
||||
FormatterOpenAIChat = "openai.chat"
|
||||
FormatterText = "text"
|
||||
FormatterGeneric = "generic"
|
||||
)
|
||||
|
||||
var dataURL = regexp.MustCompile(`^data:([^;,]+);base64,(.+)$`)
|
||||
|
||||
// Provider spellings of the finish reasons; the enum values themselves pass through.
|
||||
var finishReasonAliases = map[string]FinishReason{
|
||||
"tool_calls": FinishReasonToolCall, "function_call": FinishReasonToolCall,
|
||||
"end_turn": FinishReasonStop, "stop_sequence": FinishReasonStop,
|
||||
"max_tokens": FinishReasonLength,
|
||||
}
|
||||
|
||||
// FormattedMessages is the OTel GenAI view of one span's messages. Formatter names the converter that
|
||||
// produced it, Warnings what the converter could not resolve. The lists are never nil.
|
||||
type FormattedMessages struct {
|
||||
Input InputMessages
|
||||
Output OutputMessages
|
||||
Formatter string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// FormatMessages builds the OTel GenAI view of gen_ai.input.messages and gen_ai.output.messages from the
|
||||
// formats SDKs write. Each value is a JSON string or a structured value; nil means absent.
|
||||
func FormatMessages(input, output any) FormattedMessages {
|
||||
f := &formatting{out: FormattedMessages{Input: InputMessages{}, Output: OutputMessages{}, Warnings: []string{}}}
|
||||
var inputLabel, outputLabel string
|
||||
if input != nil {
|
||||
var msgs OutputMessages
|
||||
msgs, inputLabel = f.side(decode(input), RoleUser)
|
||||
for _, m := range msgs {
|
||||
f.out.Input = append(f.out.Input, ChatMessage{Role: m.Role, Parts: m.Parts, Name: m.Name})
|
||||
}
|
||||
}
|
||||
if output != nil {
|
||||
f.out.Output, outputLabel = f.side(decode(output), RoleAssistant)
|
||||
}
|
||||
f.out.Formatter = inputLabel
|
||||
if inputLabel == "" {
|
||||
f.out.Formatter = outputLabel
|
||||
} else if outputLabel != "" && outputLabel != inputLabel {
|
||||
f.warn("output formatted as %s, input as %s", outputLabel, inputLabel)
|
||||
}
|
||||
return f.out
|
||||
}
|
||||
|
||||
type formatting struct {
|
||||
out FormattedMessages
|
||||
}
|
||||
|
||||
func (f *formatting) warn(format string, args ...any) {
|
||||
f.out.Warnings = append(f.out.Warnings, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// side converts one attribute value by its shape; role is what a bare string on this side is
|
||||
// spoken as.
|
||||
func (f *formatting) side(value any, role Role) (OutputMessages, string) {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
if first, ok := firstObject(v); ok {
|
||||
if _, ok := first["parts"]; ok {
|
||||
return f.semconv(v), FormatterSemconv
|
||||
}
|
||||
if _, ok := first["role"]; ok {
|
||||
return f.openAIChatMessages(v), FormatterOpenAIChat
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if choices, ok := v["choices"].([]any); ok {
|
||||
return f.openAIChatResponse(choices), FormatterOpenAIChat
|
||||
}
|
||||
if messages, ok := v["messages"].([]any); ok {
|
||||
return f.openAIChatMessages(messages), FormatterOpenAIChat
|
||||
}
|
||||
case string:
|
||||
if v == "" {
|
||||
return OutputMessages{}, FormatterText
|
||||
}
|
||||
f.warn("bare %s text, role assumed", role.StringValue())
|
||||
return OutputMessages{{Role: role, Parts: Parts{part(textPart(v))}}}, FormatterText
|
||||
}
|
||||
f.warn("message format not recognised, kept as generic")
|
||||
return OutputMessages{genericMessage(value)}, FormatterGeneric
|
||||
}
|
||||
|
||||
func firstObject(list []any) (map[string]any, bool) {
|
||||
if len(list) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
first, ok := list[0].(map[string]any)
|
||||
return first, ok
|
||||
}
|
||||
|
||||
func genericMessage(value any) OutputMessage {
|
||||
return OutputMessage{Parts: Parts{part(GenericPart{"type": FormatterGeneric, "content": stringOf(value)})}}
|
||||
}
|
||||
|
||||
// decode parses a JSON-encoded attribute value, twice when an SDK encoded the string again.
|
||||
// Anything that is not JSON is returned as is.
|
||||
func decode(value any) any {
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed == "" || !strings.ContainsAny(trimmed[:1], `[{"`) {
|
||||
return s
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
|
||||
return s
|
||||
}
|
||||
if inner, ok := decoded.(string); ok {
|
||||
return decode(inner)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func role(value string) Role {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
switch lower {
|
||||
case "human":
|
||||
return RoleUser
|
||||
case "ai", "model":
|
||||
return RoleAssistant
|
||||
case "function":
|
||||
return RoleTool
|
||||
}
|
||||
return Role{String: valuer.NewString(lower)}
|
||||
}
|
||||
|
||||
func finishReason(value string) *FinishReason {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
if lower == "" {
|
||||
return nil
|
||||
}
|
||||
fr, ok := finishReasonAliases[lower]
|
||||
if !ok {
|
||||
fr = FinishReason{String: valuer.NewString(lower)}
|
||||
}
|
||||
return &fr
|
||||
}
|
||||
|
||||
func optionalString(value any) *string {
|
||||
s := stringOf(value)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func part(value any) Part {
|
||||
return Part{Value: value}
|
||||
}
|
||||
|
||||
func textPart(content string) TextPart {
|
||||
return TextPart{Type: PartTypeText, Content: content}
|
||||
}
|
||||
|
||||
// contentPart converts one OpenAI content block, which semconv parts may also carry; a block of
|
||||
// any other type is kept as sent.
|
||||
func contentPart(item any) Part {
|
||||
p, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
if s, ok := item.(string); ok {
|
||||
return part(textPart(s))
|
||||
}
|
||||
return part(GenericPart{"type": FormatterGeneric, "content": stringOf(item)})
|
||||
}
|
||||
switch stringOf(p["type"]) {
|
||||
case "text":
|
||||
return part(textPart(stringOf(p["text"])))
|
||||
case "refusal":
|
||||
return part(textPart(stringOf(p["refusal"])))
|
||||
case "image_url":
|
||||
url := stringOf(p["image_url"])
|
||||
if ref, ok := p["image_url"].(map[string]any); ok {
|
||||
url = stringOf(ref["url"])
|
||||
}
|
||||
if url != "" {
|
||||
return part(mediaPart(url, ModalityImage))
|
||||
}
|
||||
case "file":
|
||||
if f, ok := filePart(p); ok {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return part(GenericPart(p))
|
||||
}
|
||||
|
||||
// filePart reads {file: {file_id}} or {file: {file_data, filename}}; Chat Completions accepts only PDF here.
|
||||
func filePart(block map[string]any) (Part, bool) {
|
||||
f, _ := block["file"].(map[string]any)
|
||||
if id := stringOf(f["file_id"]); id != "" {
|
||||
mime := "application/pdf"
|
||||
return part(FilePart{Type: PartTypeFile, Modality: ModalityDocument, FileID: id, MimeType: &mime}), true
|
||||
}
|
||||
if data := stringOf(f["file_data"]); data != "" {
|
||||
return part(mediaPart(data, ModalityDocument)), true
|
||||
}
|
||||
return Part{}, false
|
||||
}
|
||||
|
||||
// mediaPart places a data URL in a BlobPart, any other URL in a UriPart.
|
||||
func mediaPart(url string, modality Modality) any {
|
||||
if m := dataURL.FindStringSubmatch(url); m != nil {
|
||||
return BlobPart{Type: PartTypeBlob, Modality: modality, MimeType: &m[1], Content: m[2]}
|
||||
}
|
||||
return UriPart{Type: PartTypeURI, Modality: modality, URI: url}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
125
pkg/types/aiobservabilitytypes/message_format_openai_chat.go
Normal file
125
pkg/types/aiobservabilitytypes/message_format_openai_chat.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Format: OpenAI Chat Completions, a request {messages}, a response {choices}, or a bare message list.
|
||||
// Written by: bifrost, openrouter requests, the raw OpenAI calls OpenInference records.
|
||||
// Handled: string and block content, tool_calls nested or flattened, tool messages, refusal,
|
||||
// reasoning_content, finish reasons.
|
||||
// Not yet: streaming delta chunks, legacy completions choices[].text, audio output.
|
||||
|
||||
func (f *formatting) openAIChatMessages(list []any) OutputMessages {
|
||||
out := make(OutputMessages, 0, len(list))
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
out = append(out, genericMessage(item))
|
||||
continue
|
||||
}
|
||||
out = append(out, f.openAIChatMessage(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openAIChatResponse converts the choices of an OpenAI chat response, one message each with its
|
||||
// finish reason.
|
||||
func (f *formatting) openAIChatResponse(choices []any) OutputMessages {
|
||||
out := make(OutputMessages, 0, len(choices))
|
||||
for _, item := range choices {
|
||||
choice, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
out = append(out, genericMessage(item))
|
||||
continue
|
||||
}
|
||||
inner, _ := choice["message"].(map[string]any)
|
||||
msg := f.openAIChatMessage(inner)
|
||||
if msg.Role.IsZero() {
|
||||
msg.Role = RoleAssistant
|
||||
}
|
||||
if fr := finishReason(stringOf(choice["finish_reason"])); fr != nil {
|
||||
msg.FinishReason = fr
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openAIChatMessage converts one {role, content, tool_calls, ...} message in the OpenAI chat shape.
|
||||
func (f *formatting) openAIChatMessage(m map[string]any) OutputMessage {
|
||||
msg := OutputMessage{Role: role(stringOf(m["role"])), Parts: Parts{}, Name: optionalString(m["name"]), FinishReason: finishReason(stringOf(m["finish_reason"]))}
|
||||
|
||||
if id := stringOf(m["tool_call_id"]); msg.Role == RoleTool && id != "" {
|
||||
msg.Parts = Parts{part(ToolCallResponsePart{Type: PartTypeToolCallResponse, ID: &id, Response: toolResponse(m["content"])})}
|
||||
return msg
|
||||
}
|
||||
|
||||
switch content := m["content"].(type) {
|
||||
case string:
|
||||
if content != "" {
|
||||
msg.Parts = append(msg.Parts, part(textPart(content)))
|
||||
}
|
||||
case []any:
|
||||
for _, item := range content {
|
||||
msg.Parts = append(msg.Parts, contentPart(item))
|
||||
}
|
||||
}
|
||||
if refusal := stringOf(m["refusal"]); refusal != "" {
|
||||
msg.Parts = append(msg.Parts, part(textPart(refusal)))
|
||||
}
|
||||
reasoning, _ := m["reasoning_content"].(string)
|
||||
if reasoning == "" {
|
||||
reasoning, _ = m["reasoning"].(string)
|
||||
}
|
||||
if reasoning != "" {
|
||||
msg.Parts = append(msg.Parts, part(ReasoningPart{Type: PartTypeReasoning, Content: reasoning}))
|
||||
}
|
||||
if calls, ok := m["tool_calls"].([]any); ok {
|
||||
for _, call := range calls {
|
||||
if c, ok := call.(map[string]any); ok {
|
||||
msg.Parts = append(msg.Parts, toolCallPart(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
if call, ok := m["function_call"].(map[string]any); ok {
|
||||
msg.Parts = append(msg.Parts, part(ToolCallRequestPart{Type: PartTypeToolCall, Name: stringOf(call["name"]), Arguments: decode(call["arguments"])}))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// toolCallPart reads {id, function: {name, arguments}}; a gateway may flatten it to {id, name, arguments|args}.
|
||||
func toolCallPart(c map[string]any) Part {
|
||||
call := ToolCallRequestPart{Type: PartTypeToolCall, ID: optionalString(c["id"]), Name: stringOf(c["name"])}
|
||||
args := c["arguments"]
|
||||
if args == nil {
|
||||
args = c["args"]
|
||||
}
|
||||
if fn, ok := c["function"].(map[string]any); ok {
|
||||
call.Name = stringOf(fn["name"])
|
||||
args = fn["arguments"]
|
||||
}
|
||||
if args != nil {
|
||||
call.Arguments = decode(args)
|
||||
}
|
||||
return part(call)
|
||||
}
|
||||
|
||||
// toolResponse parses a JSON string and joins text blocks; anything else stays as sent.
|
||||
func toolResponse(value any) any {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return decode(v)
|
||||
case []any:
|
||||
texts := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
block, ok := item.(map[string]any)
|
||||
if !ok || stringOf(block["type"]) != "text" {
|
||||
return v
|
||||
}
|
||||
texts = append(texts, stringOf(block["text"]))
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import "testing"
|
||||
|
||||
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored
|
||||
// them for the SigNoz/scripts static-telemetry-generator captures.
|
||||
func TestFormat_OpenAIChat(t *testing.T) {
|
||||
runFormatCases(t, []formatCase{
|
||||
{
|
||||
// openinference capture, ChatCompletion span: the raw OpenAI request and response
|
||||
name: "ResponseWithToolCalls_ToolCallParts_FinishReasonToolCall",
|
||||
input: `{"messages": [{"role": "user", "content": "What's the weather in Paris and in London? Use the tool for each."}], "model": "gpt-4o-mini", "max_tokens": 120, "tool_choice": "auto", "tools": [{"type": "function", "function": {"name": "get_current_weather", "description": "Get the current weather for a city.", "parameters": {"type": "object", "properties": {"city": {"type": "string"}, "unit": {"type": "string", "enum": ["c", "f"]}}, "required": ["city"]}}}]}`,
|
||||
output: `{"id":"chatcmpl-DksBDptnQ3aTu18YrqSdYQgiPOuRV","choices":[{"finish_reason":"tool_calls","index":0,"logprobs":null,"message":{"content":null,"refusal":null,"role":"assistant","annotations":[],"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"}]}}],"created":1780063727,"model":"gpt-4o-mini-2024-07-18","object":"chat.completion","service_tier":"default","system_fingerprint":"fp_6e71a9f378","usage":{"completion_tokens":46,"prompt_tokens":71,"total_tokens":117,"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}}}`,
|
||||
formatter: FormatterOpenAIChat,
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Paris and in London? Use the tool for each."
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "get_current_weather",
|
||||
"id": "call_SW4hst7nzysgvWSAz4OdyK9D",
|
||||
"arguments": {
|
||||
"city": "Paris"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "get_current_weather",
|
||||
"id": "call_si8wiETYGpooMB7gMXSgMQpk",
|
||||
"arguments": {
|
||||
"city": "London"
|
||||
}
|
||||
}
|
||||
],
|
||||
"finish_reason": "tool_call"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// openinference capture, the follow-up ChatCompletion span
|
||||
name: "ToolRoleMessages_BecomeToolCallResponseParts",
|
||||
input: `{"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}`,
|
||||
output: `{"id":"chatcmpl-DksBF2QuaoMMMFg9UAXX9Rc28XVJT","choices":[{"finish_reason":"stop","index":0,"logprobs":null,"message":{"content":"The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear\n\nBoth cities are enjoying clear skies with the same temperature.","refusal":null,"role":"assistant","annotations":[]}}],"created":1780063729,"model":"gpt-4o-mini-2024-07-18","object":"chat.completion","service_tier":"default","system_fingerprint":"fp_e2d886d409","usage":{"completion_tokens":40,"prompt_tokens":118,"total_tokens":158,"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}}}`,
|
||||
formatter: FormatterOpenAIChat,
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Paris and in London? Use the tool for each."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "get_current_weather",
|
||||
"id": "call_SW4hst7nzysgvWSAz4OdyK9D",
|
||||
"arguments": {
|
||||
"city": "Paris"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "get_current_weather",
|
||||
"id": "call_si8wiETYGpooMB7gMXSgMQpk",
|
||||
"arguments": {
|
||||
"city": "London"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call_response",
|
||||
"response": {
|
||||
"city": "Paris",
|
||||
"summary": "Clear",
|
||||
"temp_c": 18
|
||||
},
|
||||
"id": "call_SW4hst7nzysgvWSAz4OdyK9D"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call_response",
|
||||
"response": {
|
||||
"city": "London",
|
||||
"summary": "Clear",
|
||||
"temp_c": 18
|
||||
},
|
||||
"id": "call_si8wiETYGpooMB7gMXSgMQpk"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "The current weather is as follows:\n\n- **Paris**: 18°C, Clear\n- **London**: 18°C, Clear\n\nBoth cities are enjoying clear skies with the same temperature."
|
||||
}
|
||||
],
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// bifrost-gateway capture, HTTP span
|
||||
name: "GatewayFlattenedToolCalls_ToolCallParts_BareInputWarns",
|
||||
input: `What's the weather in Paris and in London? Use the tool for each.`,
|
||||
output: `[{"role":"assistant","content":"","tool_calls":[{"id":"call_xP33RT5exgBrNsvsWQZwiY9D","type":"function","name":"get_current_weather","args":"{\"city\": \"Paris\"}"},{"id":"call_lC6GgT8JW2ygJqWEM2wcohIz","type":"function","name":"get_current_weather","args":"{\"city\": \"London\"}"}]}]`,
|
||||
formatter: FormatterText,
|
||||
warnings: []string{"bare user text, role assumed", "output formatted as openai.chat, input as text"},
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What's the weather in Paris and in London? Use the tool for each."
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "get_current_weather",
|
||||
"id": "call_xP33RT5exgBrNsvsWQZwiY9D",
|
||||
"arguments": {
|
||||
"city": "Paris"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "get_current_weather",
|
||||
"id": "call_lC6GgT8JW2ygJqWEM2wcohIz",
|
||||
"arguments": {
|
||||
"city": "London"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// openinference capture, input.value of the ChatCompletion span
|
||||
name: "FileByID_FilePart",
|
||||
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"file_id": "file-6F2ksmvXxt4VdoqmHRw6kL"}}]}], "max_tokens": 60}`,
|
||||
output: `{"id":"chatcmpl-DktFileCapture0001","object":"chat.completion","model":"gpt-4o-mini-2024-07-18","choices":[{"index":0,"finish_reason":"stop","logprobs":null,"message":{"role":"assistant","content":"The document is a short PDF about dragons.","refusal":null,"annotations":[]}}]}`,
|
||||
formatter: FormatterOpenAIChat,
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"type": "text", "content": "Summarise this document in one sentence."},
|
||||
{"type": "file", "modality": "document", "file_id": "file-6F2ksmvXxt4VdoqmHRw6kL", "mime_type": "application/pdf"}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [{"type": "text", "content": "The document is a short PDF about dragons."}],
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// openinference capture, input.value of the ChatCompletion span
|
||||
name: "FileInline_BlobPart",
|
||||
input: `{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": [{"type": "text", "text": "Summarise this document in one sentence."}, {"type": "file", "file": {"filename": "draconomicon.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbXS9Db3VudCAwPj5lbmRvYmoKdHJhaWxlcjw8L1Jvb3QgMSAwIFI+PgolJUVPRgo="}}]}], "max_tokens": 60}`,
|
||||
formatter: FormatterOpenAIChat,
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"type": "text", "content": "Summarise this document in one sentence."},
|
||||
{"type": "blob", "modality": "document", "mime_type": "application/pdf", "content": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbXS9Db3VudCAwPj5lbmRvYmoKdHJhaWxlcjw8L1Jvb3QgMSAwIFI+PgolJUVPRgo="}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
{
|
||||
name: "ResponseWithScalarChoice_KeptAsGeneric",
|
||||
output: `{"choices":["oops",{"finish_reason":"stop","index":1,"message":{"role":"assistant","content":"hi"}}]}`,
|
||||
formatter: FormatterOpenAIChat,
|
||||
wantInput: `[]`,
|
||||
wantOutput: `[
|
||||
{"role": "", "parts": [{"type": "generic", "content": "oops"}]},
|
||||
{"role": "assistant", "parts": [{"type": "text", "content": "hi"}], "finish_reason": "stop"}
|
||||
]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
68
pkg/types/aiobservabilitytypes/message_format_semconv.go
Normal file
68
pkg/types/aiobservabilitytypes/message_format_semconv.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
)
|
||||
|
||||
// Format: the OTel shape itself, [{role, parts}].
|
||||
// Written by: litellm, langchain, openllmetry, portkey, traceloop LLM spans.
|
||||
// Decoding is the genai types. Repairs: "text" used for "content" on text parts; OpenAI content
|
||||
// blocks left inside parts.
|
||||
// Not yet: "thinking" for "reasoning"; "result" or "output" for "response" on tool call responses.
|
||||
|
||||
// semconv decodes messages that already follow the schema through the genai types. A part of a
|
||||
// type the schema does not name is a provider content block, which contentPart converts.
|
||||
func (f *formatting) semconv(list []any) OutputMessages {
|
||||
data, err := json.Marshal(semconvTextKey(list))
|
||||
if err != nil {
|
||||
f.warn("messages could not be encoded: %s", err)
|
||||
return OutputMessages{genericMessage(list)}
|
||||
}
|
||||
var msgs OutputMessages
|
||||
if err := json.Unmarshal(data, &msgs); err != nil {
|
||||
f.warn("messages do not follow the schema: %s", err)
|
||||
return OutputMessages{genericMessage(list)}
|
||||
}
|
||||
for i := range msgs {
|
||||
if msgs[i].Parts == nil {
|
||||
msgs[i].Parts = Parts{}
|
||||
}
|
||||
for j, p := range msgs[i].Parts {
|
||||
if block, ok := p.Value.(GenericPart); ok {
|
||||
msgs[i].Parts[j] = contentPart(map[string]any(block))
|
||||
}
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// semconvTextKey copies the text and reasoning parts some SDKs write with "text" in place of the
|
||||
// schema's "content". The input is left untouched, it is also the span's raw attribute.
|
||||
func semconvTextKey(list []any) []any {
|
||||
out := make([]any, 0, len(list))
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
parts, hasParts := m["parts"].([]any)
|
||||
if !ok || !hasParts {
|
||||
out = append(out, item)
|
||||
continue
|
||||
}
|
||||
fixedParts := make([]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
part, ok := p.(map[string]any)
|
||||
typ := stringOf(part["type"])
|
||||
if ok && (typ == "text" || typ == "reasoning") && part["content"] == nil && part["text"] != nil {
|
||||
fixed := maps.Clone(part)
|
||||
fixed["content"] = fixed["text"]
|
||||
delete(fixed, "text")
|
||||
p = fixed
|
||||
}
|
||||
fixedParts = append(fixedParts, p)
|
||||
}
|
||||
msg := maps.Clone(m)
|
||||
msg["parts"] = fixedParts
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
163
pkg/types/aiobservabilitytypes/message_format_semconv_test.go
Normal file
163
pkg/types/aiobservabilitytypes/message_format_semconv_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package aiobservabilitytypes
|
||||
|
||||
import "testing"
|
||||
|
||||
// Captured cases are gen_ai.input.messages and gen_ai.output.messages as the collector stored
|
||||
// them for the SigNoz/scripts static-telemetry-generator captures.
|
||||
func TestFormat_Semconv(t *testing.T) {
|
||||
runFormatCases(t, []formatCase{
|
||||
{
|
||||
// litellm capture, litellm_request span
|
||||
name: "SemconvMessages_DecodedUnchanged",
|
||||
input: `[{"role": "system", "parts": [{"type": "text", "content": "You are a concise assistant."}]}, {"role": "user", "parts": [{"type": "text", "content": "Give me a one-line definition of observability."}]}]`,
|
||||
output: `[{"role": "assistant", "parts": [{"type": "text", "content": "Observability is the ability to understand the internal state of a system based on external outputs, enabling effective monitoring and troubleshooting."}], "finish_reason": "stop"}]`,
|
||||
formatter: FormatterSemconv,
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "system",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "You are a concise assistant."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Give me a one-line definition of observability."
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Observability is the ability to understand the internal state of a system based on external outputs, enabling effective monitoring and troubleshooting."
|
||||
}
|
||||
],
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// litellm capture, vision call; base64 trimmed
|
||||
name: "SemconvTextSpelledAsTextKey_ContentFilled_ImageBlockBecomesBlob",
|
||||
input: `[{"role": "user", "parts": [{"type": "text", "text": "What animal is in this image? One sentence."}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD"}}]}]`,
|
||||
output: `[{"role": "assistant", "parts": [{"type": "text", "content": "The animal in the image is a cartoon duck."}], "finish_reason": "stop"}]`,
|
||||
formatter: FormatterSemconv,
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "What animal is in this image? One sentence."
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"modality": "image",
|
||||
"content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD",
|
||||
"mime_type": "image/jpeg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "The animal in the image is a cartoon duck."
|
||||
}
|
||||
],
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
// langchain capture, ChatOpenAI.chat span
|
||||
name: "SemconvToolCallTurn_DecodedUnchanged",
|
||||
input: `[{"role": "user", "parts": [{"type": "text", "content": "Search for SigNoz AI observability docs, then tell me the weather in Bengaluru."}]}, {"role": "assistant", "parts": [{"type": "tool_call", "id": "call_jL9UhSdUC9wWn2qfWwOjpzFd", "name": "search_web", "arguments": {"query": "SigNoz AI observability documentation"}}, {"type": "tool_call", "id": "call_nkVdZ8xX8d17HtmVddbUgJUc", "name": "get_weather", "arguments": {"city": "Bengaluru"}}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_jL9UhSdUC9wWn2qfWwOjpzFd", "response": "{\"query\": \"SigNoz AI observability documentation\", \"results\": [{\"title\": \"SigNoz docs: AI observability\", \"snippet\": \"...\"}, {\"title\": \"OpenTelemetry GenAI conventions\", \"snippet\": \"...\"}]}"}]}, {"role": "tool", "parts": [{"type": "tool_call_response", "id": "call_nkVdZ8xX8d17HtmVddbUgJUc", "response": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}"}]}]`,
|
||||
output: `[{"role": "assistant", "parts": [{"type": "text", "content": "I found some results for the SigNoz AI observability documentation. Here's one of the titles you might be interested in:\n\n- **SigNoz docs: AI observability**\n\nUnfortunately, a snippet was not provided, but you can look it up directly for more details.\n\nAs for the weather in Bengaluru, it's currently 18\u00b0C and clear."}], "finish_reason": "stop"}]`,
|
||||
formatter: FormatterSemconv,
|
||||
wantInput: `[
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Search for SigNoz AI observability docs, then tell me the weather in Bengaluru."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "search_web",
|
||||
"id": "call_jL9UhSdUC9wWn2qfWwOjpzFd",
|
||||
"arguments": {
|
||||
"query": "SigNoz AI observability documentation"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool_call",
|
||||
"name": "get_weather",
|
||||
"id": "call_nkVdZ8xX8d17HtmVddbUgJUc",
|
||||
"arguments": {
|
||||
"city": "Bengaluru"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call_response",
|
||||
"response": "{\"query\": \"SigNoz AI observability documentation\", \"results\": [{\"title\": \"SigNoz docs: AI observability\", \"snippet\": \"...\"}, {\"title\": \"OpenTelemetry GenAI conventions\", \"snippet\": \"...\"}]}",
|
||||
"id": "call_jL9UhSdUC9wWn2qfWwOjpzFd"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"parts": [
|
||||
{
|
||||
"type": "tool_call_response",
|
||||
"response": "{\"city\": \"Bengaluru\", \"temp_c\": 18, \"summary\": \"Clear\"}",
|
||||
"id": "call_nkVdZ8xX8d17HtmVddbUgJUc"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`,
|
||||
wantOutput: `[
|
||||
{
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"content": "I found some results for the SigNoz AI observability documentation. Here's one of the titles you might be interested in:\n\n- **SigNoz docs: AI observability**\n\nUnfortunately, a snippet was not provided, but you can look it up directly for more details.\n\nAs for the weather in Bengaluru, it's currently 18°C and clear."
|
||||
}
|
||||
],
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "StructuredSemconvValue_DecodedWithoutJSONString",
|
||||
input: []any{map[string]any{"role": "user", "parts": []any{map[string]any{"type": "text", "content": "hi"}}}},
|
||||
formatter: FormatterSemconv,
|
||||
wantInput: `[{"role": "user", "parts": [{"type": "text", "content": "hi"}]}]`,
|
||||
wantOutput: `[]`,
|
||||
},
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user