forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbetaSessionTracing.ts
More file actions
491 lines (439 loc) · 15.5 KB
/
Copy pathbetaSessionTracing.ts
File metadata and controls
491 lines (439 loc) · 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
/**
* Beta Session Tracing for Claude Code
*
* This module contains beta tracing features enabled when
* ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT are set.
*
* For external users, tracing is enabled in SDK/headless mode, or in
* interactive mode when the org is allowlisted via the
* tengu_trace_lantern GrowthBook gate.
* For ant users, tracing is enabled in all modes.
*
* Visibility Rules:
* | Content | External | Ant |
* |------------------|----------|------|
* | System prompts | ✅ | ✅ |
* | Model output | ✅ | ✅ |
* | Thinking output | ❌ | ✅ |
* | Tools | ✅ | ✅ |
* | new_context | ✅ | ✅ |
*
* Features:
* - Per-agent message tracking with hash-based deduplication
* - System prompt logging (once per unique hash)
* - Hook execution spans
* - Detailed new_context attributes for LLM requests
*/
import type { Span } from '@opentelemetry/api'
import { createHash } from 'crypto'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
import type { AssistantMessage, UserMessage } from '../../types/message.js'
import { isEnvTruthy } from '../envUtils.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import { logOTelEvent } from './events.js'
// Message type for API calls (UserMessage or AssistantMessage)
type APIMessage = UserMessage | AssistantMessage
/**
* Track hashes we've already logged this session (system prompts, tools, etc).
*
* WHY: System prompts and tool schemas are large and rarely change within a session.
* Sending full content on every request would be wasteful. Instead, we hash and
* only log the full content once per unique hash.
*/
const seenHashes = new Set<string>()
/**
* Track the last reported message hash per querySource (agent) for incremental context.
*
* WHY: When debugging traces, we want to see what NEW information was added each turn,
* not the entire conversation history (which can be huge). By tracking the last message
* we reported per agent, we can compute and send only the delta (new messages since
* the last request). This is tracked per-agent (querySource) because different agents
* (main thread, subagents, warmup requests) have independent conversation contexts.
*/
const lastReportedMessageHash = new Map<string, string>()
/**
* Clear tracking state after compaction.
* Old hashes are irrelevant once messages have been replaced.
*/
export function clearBetaTracingState(): void {
seenHashes.clear()
lastReportedMessageHash.clear()
}
const MAX_CONTENT_SIZE = 60 * 1024 // 60KB (Honeycomb limit is 64KB, staying safe)
/**
* Check if beta detailed tracing is enabled.
* - Requires ENABLE_BETA_TRACING_DETAILED=1 and BETA_TRACING_ENDPOINT
* - For external users, enabled in SDK/headless mode OR when org is
* allowlisted via the tengu_trace_lantern GrowthBook gate
*/
export function isBetaTracingEnabled(): boolean {
const baseEnabled =
isEnvTruthy(process.env.ENABLE_BETA_TRACING_DETAILED) &&
Boolean(process.env.BETA_TRACING_ENDPOINT)
if (!baseEnabled) {
return false
}
// For external users, enable in SDK/headless mode OR when org is allowlisted.
// Gate reads from disk cache, so first run after allowlisting returns false;
// works from second run onward (same behavior as enhanced_telemetry_beta).
if (process.env.USER_TYPE !== 'ant') {
return (
getIsNonInteractiveSession() ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_trace_lantern', false)
)
}
return true
}
/**
* Truncate content to fit within Honeycomb limits.
*/
export function truncateContent(
content: string,
maxSize: number = MAX_CONTENT_SIZE,
): { content: string; truncated: boolean } {
if (content.length <= maxSize) {
return { content, truncated: false }
}
return {
content:
content.slice(0, maxSize) +
'\n\n[TRUNCATED - Content exceeds 60KB limit]',
truncated: true,
}
}
/**
* Generate a short hash (first 12 hex chars of SHA-256).
*/
function shortHash(content: string): string {
return createHash('sha256').update(content).digest('hex').slice(0, 12)
}
/**
* Generate a hash for a system prompt.
*/
function hashSystemPrompt(systemPrompt: string): string {
return `sp_${shortHash(systemPrompt)}`
}
/**
* Generate a hash for a message based on its content.
*/
function hashMessage(message: APIMessage): string {
const content = jsonStringify(message.message.content)
return `msg_${shortHash(content)}`
}
// Regex to detect content wrapped in <system-reminder> tags
const SYSTEM_REMINDER_REGEX =
/^<system-reminder>\n?([\s\S]*?)\n?<\/system-reminder>$/
/**
* Check if text is entirely a system reminder (wrapped in <system-reminder> tags).
* Returns the inner content if it is, null otherwise.
*/
function extractSystemReminderContent(text: string): string | null {
const match = text.trim().match(SYSTEM_REMINDER_REGEX)
return match && match[1] ? match[1].trim() : null
}
/**
* Result of formatting messages - separates regular content from system reminders.
*/
interface FormattedMessages {
contextParts: string[]
systemReminders: string[]
}
/**
* Format user messages for new_context display, separating system reminders.
* Only handles user messages (assistant messages are filtered out before this is called).
*/
function formatMessagesForContext(messages: UserMessage[]): FormattedMessages {
const contextParts: string[] = []
const systemReminders: string[] = []
for (const message of messages) {
const content = message.message.content
if (typeof content === 'string') {
const reminderContent = extractSystemReminderContent(content)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(`[USER]\n${content}`)
}
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text') {
const reminderContent = extractSystemReminderContent(block.text)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(`[USER]\n${block.text}`)
}
} else if (block.type === 'tool_result') {
const resultContent =
typeof block.content === 'string'
? block.content
: jsonStringify(block.content)
// Tool results can also contain system reminders (e.g., malware warning)
const reminderContent = extractSystemReminderContent(resultContent)
if (reminderContent) {
systemReminders.push(reminderContent)
} else {
contextParts.push(
`[TOOL RESULT: ${block.tool_use_id}]\n${resultContent}`,
)
}
}
}
}
}
return { contextParts, systemReminders }
}
export interface LLMRequestNewContext {
/** System prompt (typically only on first request or if changed) */
systemPrompt?: string
/** Query source identifying the agent/purpose (e.g., 'repl_main_thread', 'agent:builtin') */
querySource?: string
/** Tool schemas sent with the request */
tools?: string
}
/**
* Add beta attributes to an interaction span.
* Adds new_context with the user prompt.
*/
export function addBetaInteractionAttributes(
span: Span,
userPrompt: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
const { content: truncatedPrompt, truncated } = truncateContent(
`[USER PROMPT]\n${userPrompt}`,
)
span.setAttributes({
new_context: truncatedPrompt,
...(truncated && {
new_context_truncated: true,
new_context_original_length: userPrompt.length,
}),
})
}
/**
* Add beta attributes to an LLM request span.
* Handles system prompt logging and new_context computation.
*/
export function addBetaLLMRequestAttributes(
span: Span,
newContext?: LLMRequestNewContext,
messagesForAPI?: APIMessage[],
): void {
if (!isBetaTracingEnabled()) {
return
}
// Add system prompt info to the span
if (newContext?.systemPrompt) {
const promptHash = hashSystemPrompt(newContext.systemPrompt)
const preview = newContext.systemPrompt.slice(0, 500)
// Always add hash, preview, and length to the span
span.setAttribute('system_prompt_hash', promptHash)
span.setAttribute('system_prompt_preview', preview)
span.setAttribute('system_prompt_length', newContext.systemPrompt.length)
// Log the full system prompt only once per unique hash this session
if (!seenHashes.has(promptHash)) {
seenHashes.add(promptHash)
// Truncate for the log if needed
const { content: truncatedPrompt, truncated } = truncateContent(
newContext.systemPrompt,
)
void logOTelEvent('system_prompt', {
system_prompt_hash: promptHash,
system_prompt: truncatedPrompt,
system_prompt_length: String(newContext.systemPrompt.length),
...(truncated && { system_prompt_truncated: 'true' }),
})
}
}
// Add tools info to the span
if (newContext?.tools) {
try {
const toolsArray = jsonParse(newContext.tools) as Record<
string,
unknown
>[]
// Build array of {name, hash} for each tool
const toolsWithHashes = toolsArray.map(tool => {
const toolJson = jsonStringify(tool)
const toolHash = shortHash(toolJson)
return {
name: typeof tool.name === 'string' ? tool.name : 'unknown',
hash: toolHash,
json: toolJson,
}
})
// Set span attribute with array of name/hash pairs
span.setAttribute(
'tools',
jsonStringify(
toolsWithHashes.map(({ name, hash }) => ({ name, hash })),
),
)
span.setAttribute('tools_count', toolsWithHashes.length)
// Log each tool's full description once per unique hash
for (const { name, hash, json } of toolsWithHashes) {
if (!seenHashes.has(`tool_${hash}`)) {
seenHashes.add(`tool_${hash}`)
const { content: truncatedTool, truncated } = truncateContent(json)
void logOTelEvent('tool', {
tool_name: sanitizeToolNameForAnalytics(name),
tool_hash: hash,
tool: truncatedTool,
...(truncated && { tool_truncated: 'true' }),
})
}
}
} catch {
// If parsing fails, log the raw tools string
span.setAttribute('tools_parse_error', true)
}
}
// Add new_context using hash-based tracking (visible to all users)
if (messagesForAPI && messagesForAPI.length > 0 && newContext?.querySource) {
const querySource = newContext.querySource
const lastHash = lastReportedMessageHash.get(querySource)
// Find where the last reported message is in the array
let startIndex = 0
if (lastHash) {
for (let i = 0; i < messagesForAPI.length; i++) {
const msg = messagesForAPI[i]
if (msg && hashMessage(msg) === lastHash) {
startIndex = i + 1 // Start after the last reported message
break
}
}
// If lastHash not found, startIndex stays 0 (send everything)
}
// Get new messages (filter out assistant messages - we only want user input/tool results)
const newMessages = messagesForAPI
.slice(startIndex)
.filter((m): m is UserMessage => m.type === 'user')
if (newMessages.length > 0) {
// Format new messages, separating system reminders from regular content
const { contextParts, systemReminders } =
formatMessagesForContext(newMessages)
// Set new_context (regular user content and tool results)
if (contextParts.length > 0) {
const fullContext = contextParts.join('\n\n---\n\n')
const { content: truncatedContext, truncated } =
truncateContent(fullContext)
span.setAttributes({
new_context: truncatedContext,
new_context_message_count: newMessages.length,
...(truncated && {
new_context_truncated: true,
new_context_original_length: fullContext.length,
}),
})
}
// Set system_reminders as a separate attribute
if (systemReminders.length > 0) {
const fullReminders = systemReminders.join('\n\n---\n\n')
const { content: truncatedReminders, truncated: remindersTruncated } =
truncateContent(fullReminders)
span.setAttributes({
system_reminders: truncatedReminders,
system_reminders_count: systemReminders.length,
...(remindersTruncated && {
system_reminders_truncated: true,
system_reminders_original_length: fullReminders.length,
}),
})
}
// Update last reported hash to the last message in the array
const lastMessage = messagesForAPI[messagesForAPI.length - 1]
if (lastMessage) {
lastReportedMessageHash.set(querySource, hashMessage(lastMessage))
}
}
}
}
/**
* Add beta attributes to endLLMRequestSpan.
* Handles model_output and thinking_output truncation.
*/
export function addBetaLLMResponseAttributes(
endAttributes: Record<string, string | number | boolean>,
metadata?: {
modelOutput?: string
thinkingOutput?: string
},
): void {
if (!isBetaTracingEnabled() || !metadata) {
return
}
// Add model_output (text content) - visible to all users
if (metadata.modelOutput !== undefined) {
const { content: modelOutput, truncated: outputTruncated } =
truncateContent(metadata.modelOutput)
endAttributes['response.model_output'] = modelOutput
if (outputTruncated) {
endAttributes['response.model_output_truncated'] = true
endAttributes['response.model_output_original_length'] =
metadata.modelOutput.length
}
}
// Add thinking_output - ant-only
if (
process.env.USER_TYPE === 'ant' &&
metadata.thinkingOutput !== undefined
) {
const { content: thinkingOutput, truncated: thinkingTruncated } =
truncateContent(metadata.thinkingOutput)
endAttributes['response.thinking_output'] = thinkingOutput
if (thinkingTruncated) {
endAttributes['response.thinking_output_truncated'] = true
endAttributes['response.thinking_output_original_length'] =
metadata.thinkingOutput.length
}
}
}
/**
* Add beta attributes to startToolSpan.
* Adds tool_input with the serialized tool input.
*/
export function addBetaToolInputAttributes(
span: Span,
toolName: string,
toolInput: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
const { content: truncatedInput, truncated } = truncateContent(
`[TOOL INPUT: ${toolName}]\n${toolInput}`,
)
span.setAttributes({
tool_input: truncatedInput,
...(truncated && {
tool_input_truncated: true,
tool_input_original_length: toolInput.length,
}),
})
}
/**
* Add beta attributes to endToolSpan.
* Adds new_context with the tool result.
*/
export function addBetaToolResultAttributes(
endAttributes: Record<string, string | number | boolean>,
toolName: string | number | boolean,
toolResult: string,
): void {
if (!isBetaTracingEnabled()) {
return
}
const { content: truncatedResult, truncated } = truncateContent(
`[TOOL RESULT: ${toolName}]\n${toolResult}`,
)
endAttributes['new_context'] = truncatedResult
if (truncated) {
endAttributes['new_context_truncated'] = true
endAttributes['new_context_original_length'] = toolResult.length
}
}