Skip to content

Commit 42a5355

Browse files
chore(example): update otel trace example (#1864)
Co-authored-by: rosetta-livekit-bot[bot] <282703043+rosetta-livekit-bot[bot]@users.noreply.github.com> Co-authored-by: Chenghao Mou <chenghao.mou@livekit.io>
1 parent 1f881c2 commit 42a5355

4 files changed

Lines changed: 270 additions & 10 deletions

File tree

.changeset/whole-dolls-shake.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@livekit/agents": patch
3+
"livekit-agents-examples": patch
4+
---
5+
6+
chore(example): update otel trace example

agents/src/telemetry/traces.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { context as otelContext, trace } from '@opentelemetry/api';
55
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
66
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
77
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8-
import { setTracerProvider, tracer } from './traces.js';
8+
import { setTracerProvider, setupCloudTracer, tracer } from './traces.js';
99

1010
/** Helper: extract parentSpanId across OTel SDK v1/v2 */
1111
function parentSpanId(span: unknown): string | undefined {
@@ -140,3 +140,50 @@ describe('register() set-once semantics', () => {
140140
expect(parentSpanId(lkSession)).toBe(userParent.spanContext().spanId);
141141
});
142142
});
143+
144+
describe('setupCloudTracer with a user-configured provider', () => {
145+
let userExporter: InMemorySpanExporter;
146+
let userProvider: NodeTracerProvider;
147+
let prevKey: string | undefined;
148+
let prevSecret: string | undefined;
149+
150+
beforeEach(() => {
151+
prevKey = process.env.LIVEKIT_API_KEY;
152+
prevSecret = process.env.LIVEKIT_API_SECRET;
153+
process.env.LIVEKIT_API_KEY = 'devkey';
154+
process.env.LIVEKIT_API_SECRET = 'secretsecretsecretsecretsecretsecret';
155+
156+
userExporter = new InMemorySpanExporter();
157+
userProvider = new NodeTracerProvider({
158+
spanProcessors: [new SimpleSpanProcessor(userExporter)],
159+
});
160+
userProvider.register();
161+
setTracerProvider(userProvider);
162+
});
163+
164+
afterEach(async () => {
165+
await userProvider.shutdown();
166+
otelContext.disable();
167+
trace.disable();
168+
// Assigning undefined to process.env.X stores the string "undefined"; delete instead so
169+
// env vars that were originally unset stay unset for later tests.
170+
if (prevKey === undefined) delete process.env.LIVEKIT_API_KEY;
171+
else process.env.LIVEKIT_API_KEY = prevKey;
172+
if (prevSecret === undefined) delete process.env.LIVEKIT_API_SECRET;
173+
else process.env.LIVEKIT_API_SECRET = prevSecret;
174+
});
175+
176+
it('does not replace the user provider (attaches the cloud exporter to it instead)', async () => {
177+
await setupCloudTracer({
178+
roomId: 'room1',
179+
jobId: 'job1',
180+
cloudHostname: 'example.livekit.cloud',
181+
enableTraces: true,
182+
enableLogs: false,
183+
});
184+
185+
// No span is created/ended here so the newly attached cloud BatchSpanProcessor has
186+
// nothing to flush over the network on shutdown.
187+
expect(tracer.getProvider()).toBe(userProvider);
188+
});
189+
});

agents/src/telemetry/traces.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ThrowsPromise } from '@livekit/throws-transformer/throws';
66
import {
77
type Attributes,
88
type Context,
9+
ProxyTracerProvider,
910
type Span,
1011
type SpanOptions,
1112
type Tracer,
@@ -75,6 +76,14 @@ class DynamicTracer {
7576
return this.tracer;
7677
}
7778

79+
/**
80+
* Returns the current tracer provider — the API's ProxyTracerProvider if none has been set
81+
* via setProvider(), which callers use to detect whether a user-configured provider exists.
82+
*/
83+
getProvider(): TracerProvider {
84+
return this.tracerProvider;
85+
}
86+
7887
/**
7988
* Start a span manually (without making it active).
8089
* You must call span.end() when done.
@@ -259,15 +268,36 @@ export async function setupCloudTracer(options: {
259268
compression: CompressionAlgorithm.GZIP,
260269
});
261270

262-
const tracerProvider = new NodeTracerProvider({
263-
resource,
264-
spanProcessors: [new MetadataSpanProcessor(metadata), new BatchSpanProcessor(spanExporter)],
265-
});
266-
// register() installs an AsyncLocalStorageContextManager (needed for span nesting)
267-
// and sets the global tracer provider. Both use set-once semantics in the OTel API,
268-
// so if the user already called NodeSDK.start(), these are safe no-ops.
269-
tracerProvider.register();
270-
setTracerProvider(tracerProvider);
271+
// If the user already configured a tracer provider (e.g. setTracerProvider in the job
272+
// entrypoint), attach the cloud exporter to it rather than replacing it, so spans reach
273+
// both the user's backend and LiveKit Cloud.
274+
const currentProvider = tracer.getProvider();
275+
const existingProvider =
276+
currentProvider instanceof ProxyTracerProvider
277+
? undefined
278+
: (currentProvider as Partial<NodeTracerProvider>);
279+
280+
if (existingProvider && typeof existingProvider.addSpanProcessor === 'function') {
281+
// The user's provider keeps its own Resource (incl. service.name): a provider has one
282+
// Resource shared by all exporters, so applying `resource` here would also relabel the
283+
// spans going to the user's own backend. room_id/job_id — the keys Cloud correlates on —
284+
// still ride along as span attributes via MetadataSpanProcessor.
285+
existingProvider.addSpanProcessor(new MetadataSpanProcessor(metadata));
286+
existingProvider.addSpanProcessor(new BatchSpanProcessor(spanExporter));
287+
} else {
288+
const tracerProvider = new NodeTracerProvider({
289+
resource,
290+
spanProcessors: [
291+
new MetadataSpanProcessor(metadata),
292+
new BatchSpanProcessor(spanExporter),
293+
],
294+
});
295+
// register() installs an AsyncLocalStorageContextManager (needed for span nesting)
296+
// and sets the global tracer provider. Both use set-once semantics in the OTel API,
297+
// so if the user already called NodeSDK.start(), these are safe no-ops.
298+
tracerProvider.register();
299+
setTracerProvider(tracerProvider);
300+
}
271301
}
272302

273303
if (enableLogs) {

examples/src/otel_trace.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import {
5+
type JobContext,
6+
ServerOptions,
7+
cli,
8+
defineAgent,
9+
inference,
10+
llm,
11+
log,
12+
logMetrics,
13+
stt,
14+
telemetry,
15+
tts,
16+
voice,
17+
} from '@livekit/agents';
18+
import * as openai from '@livekit/agents-plugin-openai';
19+
import { type Attributes } from '@opentelemetry/api';
20+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
21+
import { BatchSpanProcessor, NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
22+
import { fileURLToPath } from 'node:url';
23+
import { z } from 'zod';
24+
25+
// This example shows how to trace the agent session with OpenTelemetry.
26+
// It exports spans over OTLP/HTTP, so it works with any OTLP-compatible backend
27+
// (Langfuse, Jaeger, Grafana Tempo, Honeycomb, etc.). To enable tracing, set the trace
28+
// provider with `telemetry.setTracerProvider` at the module level or inside the entrypoint
29+
// before `AgentSession.start()`.
30+
//
31+
// Configure the destination either by passing `url`/`headers` to `setupOtel`, or by leaving
32+
// them unset and exporting the standard OTLP environment variables:
33+
// OTEL_EXPORTER_OTLP_ENDPOINT=https://my-collector.example.com
34+
// OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer <token>
35+
//
36+
// Worked example - Langfuse: the endpoint is `<LANGFUSE_HOST>/api/public/otel` and auth
37+
// is a base64-encoded `Authorization: Basic` header built from the public/secret keys:
38+
// const auth = Buffer.from(`${publicKey}:${secretKey}`).toString('base64');
39+
// setupOtel({
40+
// url: `${host.replace(/\/$/, '')}/api/public/otel`,
41+
// headers: { Authorization: `Basic ${auth}`, 'x-langfuse-ingestion-version': '4' },
42+
// });
43+
// Refer to their docs for latest instructions: https://langfuse.com/integrations/native/opentelemetry#opentelemetry-endpoint
44+
function setupOtel(options?: {
45+
metadata?: Attributes;
46+
url?: string;
47+
headers?: Record<string, string>;
48+
}): NodeTracerProvider {
49+
const traceExporter = new OTLPTraceExporter({
50+
url: options?.url,
51+
headers: options?.headers,
52+
});
53+
const traceProvider = new NodeTracerProvider({
54+
spanProcessors: [new BatchSpanProcessor(traceExporter)],
55+
});
56+
57+
traceProvider.register();
58+
telemetry.setTracerProvider(traceProvider, { metadata: options?.metadata });
59+
return traceProvider;
60+
}
61+
62+
// Resolve the logger lazily: log() requires initializeLogger() to have run, which the CLI
63+
// only does after this module is imported, so calling it at module scope would throw.
64+
const logger = () => log().child({ example: 'otel-trace-example' });
65+
66+
const lookupWeather = llm.tool({
67+
name: 'lookupWeather',
68+
description: 'Called when the user asks for weather related information.',
69+
parameters: z.object({
70+
location: z.string().describe('The location they are asking for'),
71+
}),
72+
execute: async ({ location }) => {
73+
logger().info({ location }, 'Looking up weather');
74+
return 'sunny with a temperature of 70 degrees.';
75+
},
76+
});
77+
78+
class Kelly extends voice.Agent {
79+
constructor() {
80+
super({
81+
instructions: 'Your name is Kelly.',
82+
tools: [
83+
lookupWeather,
84+
llm.tool({
85+
name: 'transferToAlloy',
86+
description: 'Transfer the call to Alloy.',
87+
parameters: z.object({}),
88+
execute: async () => {
89+
logger().info('Transferring the call to Alloy');
90+
return llm.handoff({ agent: new Alloy(), returns: 'Transfer complete.' });
91+
},
92+
}),
93+
],
94+
});
95+
}
96+
97+
async onEnter() {
98+
logger().info('Kelly is entering the session');
99+
this.session.generateReply();
100+
}
101+
}
102+
103+
class Alloy extends voice.Agent {
104+
constructor() {
105+
super({
106+
instructions: 'Your name is Alloy.',
107+
llm: new openai.realtime.RealtimeModel({ voice: 'alloy' }),
108+
tools: [
109+
lookupWeather,
110+
llm.tool({
111+
name: 'transferToKelly',
112+
description: 'Transfer the call to Kelly.',
113+
parameters: z.object({}),
114+
execute: async () => {
115+
logger().info('Transferring the call to Kelly');
116+
return llm.handoff({ agent: new Kelly(), returns: 'Transfer complete.' });
117+
},
118+
}),
119+
],
120+
});
121+
}
122+
123+
async onEnter() {
124+
logger().info('Alloy is entering the session');
125+
this.session.generateReply();
126+
}
127+
}
128+
129+
export default defineAgent({
130+
entry: async (ctx: JobContext) => {
131+
// Set up the OpenTelemetry tracer.
132+
const traceProvider = setupOtel({
133+
// Metadata is set as attributes on all spans created by the tracer; some backends have
134+
// their own grouping conventions (e.g. Langfuse uses `langfuse.session.id` or `session.id`).
135+
metadata: {
136+
'session.id': ctx.room.name,
137+
},
138+
});
139+
140+
// Optional: add a shutdown callback to flush the trace before process exit.
141+
ctx.addShutdownCallback(async () => {
142+
await traceProvider.forceFlush();
143+
});
144+
145+
const session = new voice.AgentSession({
146+
llm: new llm.FallbackAdapter({
147+
llms: [
148+
new inference.LLM({ model: 'openai/gpt-4.1-mini' }),
149+
new inference.LLM({ model: 'google/gemini-2.5-flash' }),
150+
],
151+
}),
152+
stt: new stt.FallbackAdapter({
153+
sttInstances: [
154+
new inference.STT({ model: 'deepgram/nova-3' }),
155+
new inference.STT({ model: 'cartesia/ink-whisper' }),
156+
],
157+
}),
158+
tts: new tts.FallbackAdapter({
159+
ttsInstances: [
160+
new inference.TTS({ model: 'cartesia/sonic-3' }),
161+
new inference.TTS({ model: 'rime/arcana' }),
162+
],
163+
}),
164+
});
165+
166+
session.on(voice.AgentSessionEventTypes.MetricsCollected, (ev) => {
167+
logMetrics(ev.metrics);
168+
});
169+
170+
await session.start({
171+
agent: new Kelly(),
172+
room: ctx.room,
173+
});
174+
},
175+
});
176+
177+
cli.runApp(new ServerOptions({ agent: fileURLToPath(import.meta.url) }));

0 commit comments

Comments
 (0)