Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/core/src/client/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import type { Attributes, Span } from '@opentelemetry/api';
import { GEN_AI, GEN_AI_OPERATION } from '../observability/attributes.js';
import { recordChatMetrics } from '../observability/metrics.js';
import {
addMessageEvents,
finishChatSpan,
inActiveSpan,
recordSpanError,
responseFinishReason,
setMessageContent,
setSystemInstructions,
spanName,
Expand Down Expand Up @@ -110,6 +112,11 @@ export function withChatTelemetry<TOptions extends ChatOptions>(
startedAt = performance.now();
setMessageContent(chatSpan, GEN_AI.inputMessages, messages);
setSystemInstructions(chatSpan, options?.instructions);
addMessageEvents(chatSpan, {
providerName: client.metadata.providerName,
messages,
...(options?.instructions === undefined ? {} : { instructions: options.instructions }),
});
try {
// Inside the `try`: a client that rejects the call synchronously — a bad model name, a
// missing key — would otherwise leave this span open and unrecorded, because the failure
Expand Down Expand Up @@ -152,6 +159,13 @@ export function withChatTelemetry<TOptions extends ChatOptions>(
}
if (failure === undefined && span !== undefined) {
finishChatSpan(span, response);
const finishReason = responseFinishReason(response);
addMessageEvents(span, {
providerName: client.metadata.providerName,
messages: response.messages,
output: true,
...(finishReason === undefined ? {} : { finishReason }),
});
}
endSpan();
},
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/observability/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export const GEN_AI = {

/** Names the per-message event, alongside the role-specific event names below. */
eventName: 'event.name',
/**
* The provider name as the v1.36.0 message events carry it. The events predate
* `gen_ai.provider.name` and kept the older key, so all four implementations stamp them with
* this one.
*/
system: 'gen_ai.system',
} as const;

/**
Expand Down
16 changes: 8 additions & 8 deletions packages/core/src/observability/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,25 +181,25 @@ describe('nested usage aggregation', () => {
});

describe('message events', () => {
it('emits one role-named event per message when content capture is on', async () => {
it('emits role-named events on the chat span when content capture is on', async () => {
configureObservability({ captureMessageContent: true });

await new Agent({ client: usingClient(), name: 'bot' }).run('hello');

const invoke = spanExporter.getFinishedSpans().find((span) => span.name === 'invoke_agent bot');
const names = invoke?.events.map((event) => event.name);
const chat = spanExporter.getFinishedSpans().find((span) => span.name.startsWith('chat'));
const names = chat?.events.map((event) => event.name);
expect(names).toEqual(['gen_ai.user.message', 'gen_ai.choice']);
const first = invoke?.events[0];
const first = chat?.events[0];
expect(first?.attributes?.['event.name']).toBe('gen_ai.user.message');
expect(first?.attributes?.role).toBe('user');
expect(String(first?.attributes?.content)).toContain('hello');
expect(JSON.parse(String(first?.attributes?.body))).toEqual({ content: 'hello' });
});

it('emits nothing without the opt-in, since the events carry message text', async () => {
await new Agent({ client: usingClient(), name: 'bot' }).run('hello');

const invoke = spanExporter.getFinishedSpans().find((span) => span.name === 'invoke_agent bot');
expect(invoke?.events).toEqual([]);
for (const span of spanExporter.getFinishedSpans()) {
expect(span.events).toEqual([]);
}
});
});

Expand Down
172 changes: 171 additions & 1 deletion packages/core/src/observability/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { message } from '../types/message.js';
import { agentResponse } from '../types/response.js';
import { GEN_AI } from './attributes.js';
import { configureObservability, getTracer } from './settings.js';
import { startAgentRunSpan } from './tracing.js';
import { addMessageEvents, responseFinishReason, startAgentRunSpan } from './tracing.js';

const exporter = new InMemorySpanExporter();
/** Names of spans that were *started*, whether or not they were ended. */
Expand Down Expand Up @@ -439,6 +439,176 @@ describe('startAgentRunSpan', () => {
});
});

describe('v1.36.0 message events', () => {
/** A tool round followed by a text answer, so every event kind appears somewhere. */
const toolTurns = [
{
contents: [
{
type: 'function_call' as const,
callId: 'c1',
name: 'get_weather',
arguments: '{"city":"Tokyo"}',
},
],
finishReason: 'tool_calls',
},
{ contents: [textContent('It is sunny.')], finishReason: 'stop' },
];

/** The JSON-decoded `body` attribute of each event on `span`, keyed by event name. */
function eventBodies(span: ReadableSpan): Array<{ name: string; body: unknown }> {
return span.events.map((event) => ({
name: event.name,
body: JSON.parse(String(event.attributes?.body)),
}));
}

it('keeps message events off the invoke_agent span', async () => {
configureObservability({ captureMessageContent: true });
await new Agent({ client: new MockChatClient(toolTurns), name: 'bot', tools: [getWeather] }).run(
'weather?',
);

const invoke = must(byName('invoke_agent bot'));
// The reference implementations emit message events only for the model invocation; the agent
// span reports content as span attributes alone.
expect(invoke.events).toEqual([]);
expect(invoke.attributes[GEN_AI.inputMessages]).toBeDefined();
expect(invoke.attributes[GEN_AI.outputMessages]).toBeDefined();
});

it('emits v1.36.0-shaped events on each chat span', async () => {
configureObservability({ captureMessageContent: true });
await new Agent({
client: new MockChatClient(toolTurns),
name: 'bot',
tools: [getWeather],
instructions: 'Be terse.',
}).run('weather?');

const chats = spans().filter((span) => span.name === 'chat mock-model');
expect(chats).toHaveLength(2);

// First round: instructions, the user turn, then the model's tool-calling choice.
expect(eventBodies(must(chats[0]))).toEqual([
{ name: 'gen_ai.system.message', body: { content: 'Be terse.' } },
{ name: 'gen_ai.user.message', body: { content: 'weather?' } },
{
name: 'gen_ai.choice',
body: {
index: 0,
finish_reason: 'tool_calls',
message: {
tool_calls: [
{
id: 'c1',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
},
],
},
},
},
]);

// Second round replays the whole exchange: the assistant's call, the tool result, the answer.
expect(eventBodies(must(chats[1]))).toEqual([
{ name: 'gen_ai.system.message', body: { content: 'Be terse.' } },
{ name: 'gen_ai.user.message', body: { content: 'weather?' } },
{
name: 'gen_ai.assistant.message',
body: {
tool_calls: [
{
id: 'c1',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' },
},
],
},
},
{ name: 'gen_ai.tool.message', body: { id: 'c1', content: 'sunny' } },
{
name: 'gen_ai.choice',
body: { index: 0, finish_reason: 'stop', message: { content: 'It is sunny.' } },
},
]);
});

it('stamps the provider on every event and steps the timestamps', async () => {
configureObservability({ captureMessageContent: true });
await new Agent({ client: new MockChatClient(toolTurns), name: 'bot', tools: [getWeather] }).run(
'weather?',
);

const chat = must(spans().find((span) => span.name === 'chat mock-model'));
expect(chat.events.length).toBeGreaterThan(1);
for (const event of chat.events) {
expect(event.attributes?.['gen_ai.system']).toBe('mock');
expect(event.attributes?.['event.name']).toBe(event.name);
}
// Compared as [seconds, nanos] tuples: collapsing an epoch hrtime into one number exceeds
// float64 integer precision and would erase the 1μs steps this asserts.
for (let i = 1; i < chat.events.length; i++) {
const [prevSec, prevNs] = must(chat.events[i - 1]).time;
const [sec, ns] = must(chat.events[i]).time;
expect(sec > prevSec || (sec === prevSec && ns > prevNs)).toBe(true);
}
});

it('degrades only the values JSON cannot encode, not the whole body', () => {
configureObservability({ captureMessageContent: true });
const cyclic: Record<string, unknown> = {};
cyclic.self = cyclic;
const span = getTracer().startSpan('chat test');
addMessageEvents(span, {
providerName: 'mock',
messages: [
{
role: 'assistant',
contents: [
textContent('calling'),
{ type: 'function_call', callId: 'c1', name: 'f', arguments: { big: 1n, loop: cyclic } },
],
},
],
});
span.end();

// A bigint or a cycle inside caller-built arguments must not cost the event its text and
// call name; only the offending values degrade.
const body = JSON.parse(String(must(byName('chat test')).events[0]?.attributes?.body));
expect(body.content).toBe('calling');
expect(body.tool_calls[0].id).toBe('c1');
expect(body.tool_calls[0].function.name).toBe('f');
expect(body.tool_calls[0].function.arguments.big).toBe('1');
expect(body.tool_calls[0].function.arguments.loop).toEqual({ self: '[circular]' });
});

it('falls back to the raw representation for the finish reason', () => {
const base = agentResponse({ messages: [] });
expect(responseFinishReason(base)).toBeUndefined();
// A provider that only reports the reason on the wire object still gets choice events.
expect(responseFinishReason({ ...base, rawRepresentation: { finish_reason: 'stop' } })).toBe('stop');
// The normalized field wins over the raw one.
expect(
responseFinishReason({ ...base, finishReason: 'length', rawRepresentation: { finish_reason: 'stop' } }),
).toBe('length');
// A non-string raw value is not a finish reason.
expect(responseFinishReason({ ...base, rawRepresentation: { finish_reason: 42 } })).toBeUndefined();
});

it('emits no choice event when the response reports no finish reason', async () => {
configureObservability({ captureMessageContent: true });
const mock = new MockChatClient([{ contents: [textContent('hi')] }]);
await new Agent({ client: mock, name: 'bot' }).run('hello');

const chat = must(spans().find((span) => span.name === 'chat mock-model'));
expect(chat.events.map((event) => event.name)).toEqual(['gen_ai.user.message']);
});
});

describe('no-op behaviour without an SDK', () => {
it('runs unchanged when no tracer provider is registered', async () => {
trace.disable();
Expand Down
Loading