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
6 changes: 6 additions & 0 deletions docs/api-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ npm run docs:build # writes docs/api/index.html
- `defineEval()`, scorers such as `exactMatch` and `toolCallOrder`, and
`llmJudge()` - agent evals run under vitest.
- `withSpan()` and `TraceExporter` - tracing for `AgentExecutor.execute()`.
`TraceExporter` is a bring-your-own-exporter interface (no exporter
ships by default); for real OpenTelemetry spans, import
`createOtelTraceExporter()` from the `@loushy/build-ai-agent/otel`
subpath (requires the optional peer dependency `@opentelemetry/api`)
instead of hand-rolling the OTel bridge - see
`examples/tracing/run-otel.ts`.
- `NoopSandbox` / `SubprocessSandbox` - sandboxing for tools that opt in via
`requiresSandbox`; guardrails such as `createCommandGuardrail()` and
`secretScanGuardrail`.
Expand Down
3 changes: 2 additions & 1 deletion examples/tracing/console-exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
* console when a span starts and another when it ends, indenting child
* spans (matched via Span.parentId) under their parent for readability.
* Useful for local development, or as a template for writing a bridge
* into a real tracing backend (see otel-exporter.ts for that).
* into a real tracing backend (see src/execution/otel.ts /
* createOtelTraceExporter() for a bundled OpenTelemetry bridge).
*/

import { Span, TraceExporter } from '../../src/execution/tracing';
Expand Down
81 changes: 0 additions & 81 deletions examples/tracing/otel-exporter.ts

This file was deleted.

17 changes: 13 additions & 4 deletions examples/tracing/run-otel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/**
* Runnable example: AgentExecutor.execute() wired to a real OpenTelemetry
* NodeTracerProvider, using OTel's own ConsoleSpanExporter so the emitted
* spans are visible without needing a real collector (LOU-E6).
* spans are visible without needing a real collector (LOU-E6, LOU-K4).
*
* The TraceExporter itself comes from the bundled, opt-in
* `createOtelTraceExporter()` (src/execution/otel.ts) - this example only
* wires up the OTel SDK plumbing (a NodeTracerProvider + span processor)
* that decides *where* the spans go; the SDK-to-OTel translation is no
* longer hand-rolled here.
*
* Run with:
* npm run example:tracing:otel
Expand All @@ -15,11 +21,11 @@ import {
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-node';
import { AgentExecutor } from '../../src/execution/AgentExecutor';
import { createOtelTraceExporter } from '../../src/execution/otel';
import { AgentBuilder } from '../../src/core';
import { AgentType } from '../../src/types';
import { ToolRegistry } from '../../src/tools';
import { LLMProvider, GenerateOptions, GenerateResult } from '../../src/providers';
import { createOtelExporter } from './otel-exporter';

/** Same tiny mock provider as run-console.ts, so this needs no API key. */
class MockProvider implements LLMProvider {
Expand Down Expand Up @@ -73,6 +79,11 @@ async function main() {
provider.register();
const tracer = provider.getTracer('loushy-tracing-example');

// In real usage the tracer name is enough - `createOtelTraceExporter()`
// will resolve one via `trace.getTracer(...)` for you. Passing the
// tracer explicitly here just reuses the one obtained above.
const exporter = createOtelTraceExporter({ tracer });

const toolRegistry = new ToolRegistry();
toolRegistry.register('getWeather', {
displayName: 'Get Weather',
Expand All @@ -90,8 +101,6 @@ async function main() {
.addTool('getWeather', { tool: 'getWeather', options: {} })
.build();

const exporter = createOtelExporter(tracer);

const result = await AgentExecutor.execute({
agent,
input: "What's the weather in Cairo?",
Expand Down
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@
"types": "./dist/data/mocks.d.ts",
"import": "./dist/data/mocks.mjs",
"require": "./dist/data/mocks.js"
},
"./otel": {
"types": "./dist/execution/otel.d.ts",
"import": "./dist/execution/otel.mjs",
"require": "./dist/execution/otel.js"
}
},
"files": [
Expand Down Expand Up @@ -105,6 +110,7 @@
"peerDependencies": {
"@ai-sdk/anthropic": "^0.0.42",
"@ai-sdk/openai": "^0.0.42",
"@opentelemetry/api": "^1.9.1",
"ai": "^4.3.19",
"ollama-ai-provider": "^1.2.0",
"tsup": "^8.0.0",
Expand All @@ -117,6 +123,9 @@
"@ai-sdk/openai": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"ollama-ai-provider": {
"optional": true
},
Expand Down
153 changes: 153 additions & 0 deletions src/execution/otel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Tracer, Span as OtelSpan, Context } from '@opentelemetry/api';
import { createOtelTraceExporter } from './otel';
import { Span } from './tracing';

/**
* A minimal fake OTel span that just records what was done to it, so
* assertions can check exact call shapes without needing a real
* TracerProvider/exporter backend.
*/
function makeFakeOtelSpan(name: string) {
return {
name,
attributes: {} as Record<string, unknown>,
ended: false,
endTime: undefined as number | undefined,
setAttribute(key: string, value: unknown) {
this.attributes[key] = value;
return this;
},
end(endTime?: number) {
this.ended = true;
this.endTime = endTime;
},
} as unknown as OtelSpan & {
name: string;
attributes: Record<string, unknown>;
ended: boolean;
endTime: number | undefined;
};
}

function makeFakeTracer() {
const startedSpans: Array<{
name: string;
options: unknown;
parentContext: unknown;
span: ReturnType<typeof makeFakeOtelSpan>;
}> = [];

const tracer: Tracer = {
startSpan: vi.fn((name: string, options: unknown, parentContext?: Context) => {
const span = makeFakeOtelSpan(name);
startedSpans.push({ name, options, parentContext, span });
return span;
}),
startActiveSpan: vi.fn() as unknown as Tracer['startActiveSpan'],
};

return { tracer, startedSpans };
}

function makeSpan(overrides: Partial<Span> = {}): Span {
return {
id: 'span-1',
name: 'agent.run',
attributes: { model: 'gpt-4' },
startTime: 1000,
...overrides,
};
}

describe('createOtelTraceExporter', () => {
let fake: ReturnType<typeof makeFakeTracer>;

beforeEach(() => {
fake = makeFakeTracer();
});

it('starts an OTel span with the SDK span name and startTime on onSpanStart', () => {
const exporter = createOtelTraceExporter({ tracer: fake.tracer });
const span = makeSpan({ id: 's1', name: 'agent.run', startTime: 1234, attributes: { model: 'gpt-4' } });

exporter.onSpanStart(span);

expect(fake.tracer.startSpan).toHaveBeenCalledTimes(1);
const [name, options] = (fake.tracer.startSpan as ReturnType<typeof vi.fn>).mock.calls[0];
expect(name).toBe('agent.run');
expect(options).toMatchObject({ startTime: 1234 });
expect(fake.startedSpans[0].span.attributes).toEqual({ model: 'gpt-4' });
});

it('translates primitive attributes with setAttribute and stringifies non-primitives', () => {
const exporter = createOtelTraceExporter({ tracer: fake.tracer });
const span = makeSpan({
id: 's1',
attributes: {
model: 'gpt-4',
tokens: 42,
cached: true,
args: { city: 'Cairo' },
skip: undefined,
},
});

exporter.onSpanStart(span);

expect(fake.startedSpans[0].span.attributes).toEqual({
model: 'gpt-4',
tokens: 42,
cached: true,
args: JSON.stringify({ city: 'Cairo' }),
});
});

it('ends the matching OTel span with the SDK span endTime and merged attributes on onSpanEnd', () => {
const exporter = createOtelTraceExporter({ tracer: fake.tracer });
const span = makeSpan({ id: 's1', attributes: { model: 'gpt-4' } });
exporter.onSpanStart(span);

const finished: Span = { ...span, endTime: 2000, attributes: { model: 'gpt-4', result: 'ok' } };
exporter.onSpanEnd(finished);

const otelSpan = fake.startedSpans[0].span;
expect(otelSpan.ended).toBe(true);
expect(otelSpan.endTime).toBe(2000);
expect(otelSpan.attributes).toEqual({ model: 'gpt-4', result: 'ok' });
});

it('is a no-op on onSpanEnd for a span id that was never started', () => {
const exporter = createOtelTraceExporter({ tracer: fake.tracer });
expect(() =>
exporter.onSpanEnd(makeSpan({ id: 'unknown', endTime: 999 }))
).not.toThrow();
});

it('starts a child span using the parent OTel context when parentId matches a started span', () => {
const exporter = createOtelTraceExporter({ tracer: fake.tracer });
const parent = makeSpan({ id: 'parent', name: 'agent.run' });
exporter.onSpanStart(parent);

const child = makeSpan({ id: 'child', name: 'llm.generate', parentId: 'parent', startTime: 1100 });
exporter.onSpanStart(child);

expect(fake.tracer.startSpan).toHaveBeenCalledTimes(2);
// The child's startSpan call should receive a parentContext distinct
// from the ambient/default context used for the root span - i.e. it
// was threaded from the parent's own context, not context.active().
const rootParentContext = fake.startedSpans[0].parentContext;
const childParentContext = fake.startedSpans[1].parentContext;
expect(childParentContext).not.toBe(rootParentContext);
});

it('defaults to a tracer resolved via trace.getTracer when no tracer option is passed', async () => {
const otelApi = await import('@opentelemetry/api');
const getTracerSpy = vi.spyOn(otelApi.trace, 'getTracer');

createOtelTraceExporter({ tracerName: 'my-agent', tracerVersion: '1.2.3' });

expect(getTracerSpy).toHaveBeenCalledWith('my-agent', '1.2.3');
getTracerSpy.mockRestore();
});
});
Loading
Loading