|
4 | 4 | */ |
5 | 5 |
|
6 | 6 | 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 | +}); |
8 | 51 |
|
9 | 52 | describe('SimpleTelemetry', () => { |
10 | 53 | let telemetry; |
@@ -278,6 +321,75 @@ describe('SimpleAppTracer', () => { |
278 | 321 |
|
279 | 322 | expect(result).toBe('executed'); |
280 | 323 | }); |
| 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 | + }); |
281 | 393 | }); |
282 | 394 |
|
283 | 395 | describe('hashContent', () => { |
|
0 commit comments