@@ -14,6 +14,136 @@ import type { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trac
1414import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
1515import { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation'
1616
17+ /**
18+ * OTel GenAI experimental semantic conventions env var. When set to a
19+ * truthy value, each `gen_ai.*` span carries the full input and
20+ * output conversation content as attributes. Mirrors the Go-side
21+ * gate in `copilot/internal/providers/telemetry.go` so operators
22+ * control both halves with one variable.
23+ *
24+ * Spec: https://opentelemetry.io/docs/specs/semconv/gen-ai/
25+ */
26+ const GENAI_CAPTURE_ENV = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'
27+
28+ /**
29+ * Attribute-size cap for `gen_ai.{input,output}.messages`. Most OTLP
30+ * backends reject attributes larger than ~64 KiB, so we truncate
31+ * proactively to keep the rest of the span alive if a conversation
32+ * runs long. Matches the Go-side cap to keep truncation behavior
33+ * symmetrical between the two halves.
34+ */
35+ const GENAI_MESSAGE_ATTR_MAX_BYTES = 60 * 1024
36+
37+ function isGenAIMessageCaptureEnabled ( ) : boolean {
38+ const raw = ( process . env [ GENAI_CAPTURE_ENV ] || '' ) . toLowerCase ( ) . trim ( )
39+ return raw === 'true' || raw === '1' || raw === 'yes'
40+ }
41+
42+ /**
43+ * Canonical OTel GenAI message shape used for both input and output
44+ * attributes. Kept minimal — only the three part types we actually
45+ * emit: `text`, `tool_call`, and `tool_call_response`. Adding more
46+ * part types is cheap, but every additional shape here has to be
47+ * mirrored in the Go serializer.
48+ */
49+ interface GenAIAgentPart {
50+ type : 'text' | 'tool_call' | 'tool_call_response'
51+ content ?: string
52+ id ?: string
53+ name ?: string
54+ arguments ?: Record < string , unknown >
55+ response ?: string
56+ }
57+
58+ interface GenAIAgentMessage {
59+ role : 'system' | 'user' | 'assistant' | 'tool'
60+ parts : GenAIAgentPart [ ]
61+ }
62+
63+ function marshalAgentMessages ( messages : GenAIAgentMessage [ ] ) : string | undefined {
64+ if ( messages . length === 0 ) return undefined
65+ const json = JSON . stringify ( messages )
66+ if ( json . length <= GENAI_MESSAGE_ATTR_MAX_BYTES ) return json
67+ // Simple tail-preserving truncation: drop from the front until we
68+ // fit. Matches the Go side's behavior. The last message is
69+ // usually the most diagnostic for span-level outcome.
70+ let remaining = messages . slice ( )
71+ while ( remaining . length > 1 ) {
72+ remaining = remaining . slice ( 1 )
73+ const candidate = JSON . stringify ( remaining )
74+ if ( candidate . length <= GENAI_MESSAGE_ATTR_MAX_BYTES ) return candidate
75+ }
76+ // Single message still over cap — truncate the text part in place
77+ // with a marker so the partial content is still readable.
78+ const only = remaining [ 0 ]
79+ for ( const part of only . parts ) {
80+ if ( part . type === 'text' && part . content ) {
81+ const headroom = GENAI_MESSAGE_ATTR_MAX_BYTES - 1024
82+ if ( part . content . length > headroom ) {
83+ part . content = `${ part . content . slice ( 0 , headroom ) } \n\n[truncated: capture cap ${ GENAI_MESSAGE_ATTR_MAX_BYTES } bytes]`
84+ }
85+ }
86+ }
87+ const final = JSON . stringify ( [ only ] )
88+ return final . length <= GENAI_MESSAGE_ATTR_MAX_BYTES ? final : undefined
89+ }
90+
91+ export interface CopilotAgentInputMessages {
92+ userMessage ?: string
93+ systemPrompt ?: string
94+ }
95+
96+ export interface CopilotAgentOutputMessages {
97+ assistantText ?: string
98+ toolCalls ?: Array < {
99+ id : string
100+ name : string
101+ arguments ?: Record < string , unknown >
102+ } >
103+ }
104+
105+ function setAgentInputMessages ( span : Span , input : CopilotAgentInputMessages ) : void {
106+ if ( ! isGenAIMessageCaptureEnabled ( ) ) return
107+ const messages : GenAIAgentMessage [ ] = [ ]
108+ if ( input . systemPrompt ) {
109+ messages . push ( {
110+ role : 'system' ,
111+ parts : [ { type : 'text' , content : input . systemPrompt } ] ,
112+ } )
113+ }
114+ if ( input . userMessage ) {
115+ messages . push ( {
116+ role : 'user' ,
117+ parts : [ { type : 'text' , content : input . userMessage } ] ,
118+ } )
119+ }
120+ const serialized = marshalAgentMessages ( messages )
121+ if ( serialized ) {
122+ span . setAttribute ( 'gen_ai.input.messages' , serialized )
123+ }
124+ }
125+
126+ function setAgentOutputMessages ( span : Span , output : CopilotAgentOutputMessages ) : void {
127+ if ( ! isGenAIMessageCaptureEnabled ( ) ) return
128+ const parts : GenAIAgentPart [ ] = [ ]
129+ if ( output . assistantText ) {
130+ parts . push ( { type : 'text' , content : output . assistantText } )
131+ }
132+ for ( const tc of output . toolCalls ?? [ ] ) {
133+ parts . push ( {
134+ type : 'tool_call' ,
135+ id : tc . id ,
136+ name : tc . name ,
137+ ...( tc . arguments ? { arguments : tc . arguments } : { } ) ,
138+ } )
139+ }
140+ if ( parts . length === 0 ) return
141+ const serialized = marshalAgentMessages ( [ { role : 'assistant' , parts } ] )
142+ if ( serialized ) {
143+ span . setAttribute ( 'gen_ai.output.messages' , serialized )
144+ }
145+ }
146+
17147/**
18148 * Reuse the generated RequestTraceV1Outcome string values for every
19149 * lifecycle outcome field. This keeps our OTel attributes, internal
@@ -262,6 +392,20 @@ export interface CopilotOtelRoot {
262392 span : Span
263393 context : Context
264394 finish : ( outcome ?: CopilotLifecycleOutcome , error ?: unknown ) => void
395+ /**
396+ * Record `gen_ai.input.messages` on the root agent span. Gated on
397+ * `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` — no-op when
398+ * capture is disabled. Safe to call multiple times; the latest
399+ * call wins.
400+ */
401+ setInputMessages : ( input : CopilotAgentInputMessages ) => void
402+ /**
403+ * Record `gen_ai.output.messages` on the root agent span. Gated on
404+ * the same env var as `setInputMessages`. Typically called from the
405+ * stream finalize callback once the assistant's final content and
406+ * invoked tool calls are known.
407+ */
408+ setOutputMessages : ( output : CopilotAgentOutputMessages ) => void
265409}
266410
267411export function startCopilotOtelRoot ( scope : CopilotOtelScope ) : CopilotOtelRoot {
@@ -300,7 +444,13 @@ export function startCopilotOtelRoot(scope: CopilotOtelScope): CopilotOtelRoot {
300444 span . end ( )
301445 }
302446
303- return { span, context : rootContext , finish }
447+ return {
448+ span,
449+ context : rootContext ,
450+ finish,
451+ setInputMessages : ( input ) => setAgentInputMessages ( span , input ) ,
452+ setOutputMessages : ( output ) => setAgentOutputMessages ( span , output ) ,
453+ }
304454}
305455
306456export async function withCopilotOtelContext < T > (
0 commit comments