Skip to content

Commit 66eb28c

Browse files
bugerclaude
andauthored
feat: store AI response output on ai.request and search.delegate spans (#529) (#530)
Add onResult callback parameter to withSpan() that enriches spans with result data before they close. Captures ai.output and ai.output_length on ai.request spans, and search.delegate.output and search.delegate.output_length on search.delegate spans. Adds truncateForSpan() helper that preserves head + tail of long text (first ~2K chars and last ~2K chars with omitted count) instead of just truncating from the front, giving better context in traces. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ccbf3cf commit 66eb28c

File tree

5 files changed

+155
-8
lines changed

5 files changed

+155
-8
lines changed

npm/src/agent/ProbeAgent.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { existsSync } from 'fs';
3838
import { readFile, stat, readdir } from 'fs/promises';
3939
import { resolve, isAbsolute, dirname, basename, normalize, sep } from 'path';
4040
import { TokenCounter } from './tokenCounter.js';
41+
import { truncateForSpan } from './simpleTelemetry.js';
4142
import { InMemoryStorageAdapter } from './storage/InMemoryStorageAdapter.js';
4243
import { HookManager, HOOK_TYPES } from './hooks/HookManager.js';
4344
import { SUPPORTED_IMAGE_EXTENSIONS, IMAGE_MIME_TYPES, isFormatSupportedByProvider } from './imageConfig.js';
@@ -4327,9 +4328,7 @@ Double-check your response based on the criteria above. If everything looks good
43274328

43284329
let aiResult;
43294330
if (this.tracer) {
4330-
const inputPreview = message.length > 1000
4331-
? message.substring(0, 1000) + '... [truncated]'
4332-
: message;
4331+
const inputPreview = truncateForSpan(message, 4096);
43334332

43344333
aiResult = await this.tracer.withSpan('ai.request', executeAIRequest, {
43354334
'ai.model': this.model,
@@ -4340,6 +4339,12 @@ Double-check your response based on the criteria above. If everything looks good
43404339
'max_tokens': maxResponseTokens,
43414340
'temperature': 0.3,
43424341
'message_count': currentMessages.length
4342+
}, (span, result) => {
4343+
const text = result?.finalText || '';
4344+
span.setAttributes({
4345+
'ai.output': truncateForSpan(text),
4346+
'ai.output_length': text.length
4347+
});
43434348
});
43444349
} else {
43454350
aiResult = await executeAIRequest();

npm/src/agent/simpleTelemetry.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@ import { existsSync, mkdirSync, createWriteStream } from 'fs';
22
import { dirname } from 'path';
33
import { patchConsole } from './otelLogBridge.js';
44

5+
/**
6+
* Truncate text for span attributes, preserving head and tail for context.
7+
* For text <= maxLen, returns as-is. For longer text, shows first half and
8+
* last half of the budget with a separator indicating omitted chars.
9+
* @param {string} text - The text to truncate
10+
* @param {number} [maxLen=4096] - Maximum output length
11+
* @returns {string} The truncated text
12+
*/
13+
export function truncateForSpan(text, maxLen = 4096) {
14+
if (!text || text.length <= maxLen) return text || '';
15+
const half = Math.floor((maxLen - 40) / 2); // 40 chars reserved for separator
16+
const omitted = text.length - half * 2;
17+
return text.substring(0, half) + `\n... [${omitted} chars omitted] ...\n` + text.substring(text.length - half);
18+
}
19+
520
/**
621
* Simple telemetry implementation for probe-agent
722
* This provides basic tracing functionality without complex OpenTelemetry dependencies
@@ -463,7 +478,7 @@ export class SimpleAppTracer {
463478
});
464479
}
465480

466-
async withSpan(spanName, fn, attributes = {}) {
481+
async withSpan(spanName, fn, attributes = {}, onResult = null) {
467482
if (!this.isEnabled()) {
468483
return fn();
469484
}
@@ -476,12 +491,19 @@ export class SimpleAppTracer {
476491
try {
477492
const result = await fn();
478493
span.setStatus('OK');
494+
if (onResult) {
495+
try {
496+
onResult(span, result);
497+
} catch (_) {
498+
// Don't let span enrichment errors break the flow
499+
}
500+
}
479501
return result;
480502
} catch (error) {
481503
span.setStatus('ERROR');
482-
span.addEvent('exception', {
504+
span.addEvent('exception', {
483505
'exception.message': error.message,
484-
'exception.stack': error.stack
506+
'exception.stack': error.stack
485507
});
486508
throw error;
487509
} finally {

npm/src/tools/vercel.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { searchSchema, querySchema, extractSchema, delegateSchema, analyzeAllSch
1313
import { existsSync } from 'fs';
1414
import { formatErrorForAI } from '../utils/error-types.js';
1515
import { annotateOutputWithHashes } from './hashline.js';
16+
import { truncateForSpan } from '../agent/simpleTelemetry.js';
1617

1718
/**
1819
* Auto-quote search query terms that contain mixed case or underscores.
@@ -551,6 +552,12 @@ export const searchTool = (options = {}) => {
551552
? await options.tracer.withSpan('search.delegate', runDelegation, {
552553
'search.query': searchQuery,
553554
'search.path': searchPath
555+
}, (span, result) => {
556+
const text = typeof result === 'string' ? result : '';
557+
span.setAttributes({
558+
'search.delegate.output': truncateForSpan(text),
559+
'search.delegate.output_length': text.length
560+
});
554561
})
555562
: await runDelegation();
556563

npm/tests/unit/search-delegate.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ describe('searchDelegate behavior', () => {
8888
expect.objectContaining({
8989
'search.query': expect.stringContaining('searchDelegate'),
9090
'search.path': expect.any(String)
91-
})
91+
}),
92+
expect.any(Function)
9293
);
9394
const extractArgs = mockExtract.mock.calls[0][0];
9495
expect(extractArgs).toEqual(expect.objectContaining({ files: expect.any(Array) }));

npm/tests/unit/simpleTelemetry.test.js

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,50 @@
44
*/
55

66
import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';
7-
import { SimpleTelemetry, SimpleAppTracer } from '../../src/agent/simpleTelemetry.js';
7+
import { SimpleTelemetry, SimpleAppTracer, truncateForSpan } from '../../src/agent/simpleTelemetry.js';
8+
9+
describe('truncateForSpan', () => {
10+
test('should return short text as-is', () => {
11+
expect(truncateForSpan('hello')).toBe('hello');
12+
expect(truncateForSpan('x'.repeat(4096))).toBe('x'.repeat(4096));
13+
});
14+
15+
test('should return empty string for falsy input', () => {
16+
expect(truncateForSpan('')).toBe('');
17+
expect(truncateForSpan(null)).toBe('');
18+
expect(truncateForSpan(undefined)).toBe('');
19+
});
20+
21+
test('should preserve head and tail for long text', () => {
22+
const text = 'H'.repeat(3000) + 'T'.repeat(3000);
23+
const result = truncateForSpan(text, 4096);
24+
25+
expect(result.length).toBeLessThanOrEqual(4096);
26+
expect(result).toMatch(/^H+/); // starts with head
27+
expect(result).toMatch(/T+$/); // ends with tail
28+
expect(result).toContain('chars omitted');
29+
});
30+
31+
test('should report correct omitted count', () => {
32+
const text = 'x'.repeat(10000);
33+
const result = truncateForSpan(text, 4096);
34+
const match = result.match(/\[(\d+) chars omitted\]/);
35+
36+
expect(match).not.toBeNull();
37+
const omitted = parseInt(match[1], 10);
38+
// head + tail + omitted should equal original length
39+
const half = Math.floor((4096 - 40) / 2);
40+
expect(omitted).toBe(10000 - half * 2);
41+
});
42+
43+
test('should respect custom maxLen', () => {
44+
const text = 'x'.repeat(500);
45+
const result = truncateForSpan(text, 100);
46+
47+
expect(result.length).toBeLessThanOrEqual(150); // some slack for separator
48+
expect(result).toContain('chars omitted');
49+
});
50+
});
851

952
describe('SimpleTelemetry', () => {
1053
let telemetry;
@@ -278,6 +321,75 @@ describe('SimpleAppTracer', () => {
278321

279322
expect(result).toBe('executed');
280323
});
324+
325+
test('should call onResult callback with span and result before span ends', async () => {
326+
let capturedSpan = null;
327+
let capturedResult = null;
328+
329+
const result = await tracer.withSpan('ai.request', async () => {
330+
return { finalText: 'AI response text' };
331+
}, { 'ai.model': 'test-model' }, (span, res) => {
332+
capturedSpan = span;
333+
capturedResult = res;
334+
span.setAttributes({
335+
'ai.output': res.finalText,
336+
'ai.output_length': res.finalText.length
337+
});
338+
});
339+
340+
expect(result).toEqual({ finalText: 'AI response text' });
341+
expect(capturedSpan).not.toBeNull();
342+
expect(capturedResult).toEqual({ finalText: 'AI response text' });
343+
// Verify the attributes were set on the span
344+
expect(capturedSpan.attributes['ai.output']).toBe('AI response text');
345+
expect(capturedSpan.attributes['ai.output_length']).toBe(16);
346+
});
347+
348+
test('should not break if onResult callback throws', async () => {
349+
const result = await tracer.withSpan('ai.request', async () => {
350+
return { finalText: 'response' };
351+
}, {}, () => {
352+
throw new Error('callback error');
353+
});
354+
355+
// Should still return the result despite callback error
356+
expect(result).toEqual({ finalText: 'response' });
357+
});
358+
359+
test('should not call onResult on error', async () => {
360+
let onResultCalled = false;
361+
362+
await expect(tracer.withSpan('ai.request', async () => {
363+
throw new Error('execution failed');
364+
}, {}, () => {
365+
onResultCalled = true;
366+
})).rejects.toThrow('execution failed');
367+
368+
expect(onResultCalled).toBe(false);
369+
});
370+
371+
test('should truncate long output in onResult callback using head+tail', async () => {
372+
let capturedSpan = null;
373+
const longText = 'A'.repeat(2500) + 'B'.repeat(2500);
374+
375+
await tracer.withSpan('search.delegate', async () => {
376+
return longText;
377+
}, { 'search.query': 'test' }, (span, result) => {
378+
capturedSpan = span;
379+
const text = typeof result === 'string' ? result : '';
380+
span.setAttributes({
381+
'search.delegate.output': truncateForSpan(text),
382+
'search.delegate.output_length': text.length
383+
});
384+
});
385+
386+
expect(capturedSpan.attributes['search.delegate.output'].length).toBeLessThan(5000);
387+
expect(capturedSpan.attributes['search.delegate.output']).toContain('chars omitted');
388+
// Should contain both head (A's) and tail (B's)
389+
expect(capturedSpan.attributes['search.delegate.output']).toMatch(/^A+/);
390+
expect(capturedSpan.attributes['search.delegate.output']).toMatch(/B+$/);
391+
expect(capturedSpan.attributes['search.delegate.output_length']).toBe(5000);
392+
});
281393
});
282394

283395
describe('hashContent', () => {

0 commit comments

Comments
 (0)