@livekit/agents 1.0.21 → 1.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/inference/api_protos.cjs +2 -2
  2. package/dist/inference/api_protos.cjs.map +1 -1
  3. package/dist/inference/api_protos.d.cts +16 -16
  4. package/dist/inference/api_protos.d.ts +16 -16
  5. package/dist/inference/api_protos.js +2 -2
  6. package/dist/inference/api_protos.js.map +1 -1
  7. package/dist/inference/stt.cjs +42 -30
  8. package/dist/inference/stt.cjs.map +1 -1
  9. package/dist/inference/stt.d.ts.map +1 -1
  10. package/dist/inference/stt.js +42 -30
  11. package/dist/inference/stt.js.map +1 -1
  12. package/dist/inference/tts.cjs +2 -3
  13. package/dist/inference/tts.cjs.map +1 -1
  14. package/dist/inference/tts.d.ts.map +1 -1
  15. package/dist/inference/tts.js +2 -3
  16. package/dist/inference/tts.js.map +1 -1
  17. package/dist/ipc/job_proc_lazy_main.cjs +35 -1
  18. package/dist/ipc/job_proc_lazy_main.cjs.map +1 -1
  19. package/dist/ipc/job_proc_lazy_main.js +13 -1
  20. package/dist/ipc/job_proc_lazy_main.js.map +1 -1
  21. package/dist/job.cjs +52 -6
  22. package/dist/job.cjs.map +1 -1
  23. package/dist/job.d.cts +2 -0
  24. package/dist/job.d.ts +2 -0
  25. package/dist/job.d.ts.map +1 -1
  26. package/dist/job.js +52 -6
  27. package/dist/job.js.map +1 -1
  28. package/dist/llm/llm.cjs +38 -3
  29. package/dist/llm/llm.cjs.map +1 -1
  30. package/dist/llm/llm.d.cts +1 -0
  31. package/dist/llm/llm.d.ts +1 -0
  32. package/dist/llm/llm.d.ts.map +1 -1
  33. package/dist/llm/llm.js +38 -3
  34. package/dist/llm/llm.js.map +1 -1
  35. package/dist/log.cjs +34 -10
  36. package/dist/log.cjs.map +1 -1
  37. package/dist/log.d.cts +7 -0
  38. package/dist/log.d.ts +7 -0
  39. package/dist/log.d.ts.map +1 -1
  40. package/dist/log.js +34 -11
  41. package/dist/log.js.map +1 -1
  42. package/dist/stt/stt.cjs +18 -5
  43. package/dist/stt/stt.cjs.map +1 -1
  44. package/dist/stt/stt.d.ts.map +1 -1
  45. package/dist/stt/stt.js +18 -5
  46. package/dist/stt/stt.js.map +1 -1
  47. package/dist/telemetry/index.cjs +23 -2
  48. package/dist/telemetry/index.cjs.map +1 -1
  49. package/dist/telemetry/index.d.cts +4 -1
  50. package/dist/telemetry/index.d.ts +4 -1
  51. package/dist/telemetry/index.d.ts.map +1 -1
  52. package/dist/telemetry/index.js +27 -2
  53. package/dist/telemetry/index.js.map +1 -1
  54. package/dist/telemetry/logging.cjs +65 -0
  55. package/dist/telemetry/logging.cjs.map +1 -0
  56. package/dist/telemetry/logging.d.cts +21 -0
  57. package/dist/telemetry/logging.d.ts +21 -0
  58. package/dist/telemetry/logging.d.ts.map +1 -0
  59. package/dist/telemetry/logging.js +40 -0
  60. package/dist/telemetry/logging.js.map +1 -0
  61. package/dist/telemetry/otel_http_exporter.cjs +144 -0
  62. package/dist/telemetry/otel_http_exporter.cjs.map +1 -0
  63. package/dist/telemetry/otel_http_exporter.d.cts +62 -0
  64. package/dist/telemetry/otel_http_exporter.d.ts +62 -0
  65. package/dist/telemetry/otel_http_exporter.d.ts.map +1 -0
  66. package/dist/telemetry/otel_http_exporter.js +120 -0
  67. package/dist/telemetry/otel_http_exporter.js.map +1 -0
  68. package/dist/telemetry/pino_otel_transport.cjs +217 -0
  69. package/dist/telemetry/pino_otel_transport.cjs.map +1 -0
  70. package/dist/telemetry/pino_otel_transport.d.cts +58 -0
  71. package/dist/telemetry/pino_otel_transport.d.ts +58 -0
  72. package/dist/telemetry/pino_otel_transport.d.ts.map +1 -0
  73. package/dist/telemetry/pino_otel_transport.js +189 -0
  74. package/dist/telemetry/pino_otel_transport.js.map +1 -0
  75. package/dist/telemetry/traces.cjs +225 -16
  76. package/dist/telemetry/traces.cjs.map +1 -1
  77. package/dist/telemetry/traces.d.cts +17 -0
  78. package/dist/telemetry/traces.d.ts +17 -0
  79. package/dist/telemetry/traces.d.ts.map +1 -1
  80. package/dist/telemetry/traces.js +211 -14
  81. package/dist/telemetry/traces.js.map +1 -1
  82. package/dist/tts/tts.cjs +68 -20
  83. package/dist/tts/tts.cjs.map +1 -1
  84. package/dist/tts/tts.d.cts +2 -0
  85. package/dist/tts/tts.d.ts +2 -0
  86. package/dist/tts/tts.d.ts.map +1 -1
  87. package/dist/tts/tts.js +68 -20
  88. package/dist/tts/tts.js.map +1 -1
  89. package/dist/utils.cjs +6 -0
  90. package/dist/utils.cjs.map +1 -1
  91. package/dist/utils.d.cts +1 -0
  92. package/dist/utils.d.ts +1 -0
  93. package/dist/utils.d.ts.map +1 -1
  94. package/dist/utils.js +5 -0
  95. package/dist/utils.js.map +1 -1
  96. package/dist/voice/agent_activity.cjs +93 -7
  97. package/dist/voice/agent_activity.cjs.map +1 -1
  98. package/dist/voice/agent_activity.d.cts +3 -0
  99. package/dist/voice/agent_activity.d.ts +3 -0
  100. package/dist/voice/agent_activity.d.ts.map +1 -1
  101. package/dist/voice/agent_activity.js +93 -7
  102. package/dist/voice/agent_activity.js.map +1 -1
  103. package/dist/voice/agent_session.cjs +122 -27
  104. package/dist/voice/agent_session.cjs.map +1 -1
  105. package/dist/voice/agent_session.d.cts +15 -0
  106. package/dist/voice/agent_session.d.ts +15 -0
  107. package/dist/voice/agent_session.d.ts.map +1 -1
  108. package/dist/voice/agent_session.js +122 -27
  109. package/dist/voice/agent_session.js.map +1 -1
  110. package/dist/voice/audio_recognition.cjs +69 -22
  111. package/dist/voice/audio_recognition.cjs.map +1 -1
  112. package/dist/voice/audio_recognition.d.cts +5 -0
  113. package/dist/voice/audio_recognition.d.ts +5 -0
  114. package/dist/voice/audio_recognition.d.ts.map +1 -1
  115. package/dist/voice/audio_recognition.js +69 -22
  116. package/dist/voice/audio_recognition.js.map +1 -1
  117. package/dist/voice/generation.cjs +43 -3
  118. package/dist/voice/generation.cjs.map +1 -1
  119. package/dist/voice/generation.d.ts.map +1 -1
  120. package/dist/voice/generation.js +43 -3
  121. package/dist/voice/generation.js.map +1 -1
  122. package/dist/voice/report.cjs +3 -2
  123. package/dist/voice/report.cjs.map +1 -1
  124. package/dist/voice/report.d.cts +7 -1
  125. package/dist/voice/report.d.ts +7 -1
  126. package/dist/voice/report.d.ts.map +1 -1
  127. package/dist/voice/report.js +3 -2
  128. package/dist/voice/report.js.map +1 -1
  129. package/package.json +8 -2
  130. package/src/inference/api_protos.ts +2 -2
  131. package/src/inference/stt.ts +48 -33
  132. package/src/inference/tts.ts +4 -3
  133. package/src/ipc/job_proc_lazy_main.ts +12 -1
  134. package/src/job.ts +59 -10
  135. package/src/llm/llm.ts +48 -5
  136. package/src/log.ts +52 -15
  137. package/src/stt/stt.ts +18 -5
  138. package/src/telemetry/index.ts +22 -4
  139. package/src/telemetry/logging.ts +55 -0
  140. package/src/telemetry/otel_http_exporter.ts +191 -0
  141. package/src/telemetry/pino_otel_transport.ts +265 -0
  142. package/src/telemetry/traces.ts +320 -20
  143. package/src/tts/tts.ts +85 -24
  144. package/src/utils.ts +5 -0
  145. package/src/voice/agent_activity.ts +140 -22
  146. package/src/voice/agent_session.ts +174 -34
  147. package/src/voice/audio_recognition.ts +85 -26
  148. package/src/voice/generation.ts +59 -7
  149. package/src/voice/report.ts +10 -4
package/src/job.ts CHANGED
@@ -14,6 +14,8 @@ import { AsyncLocalStorage } from 'node:async_hooks';
14
14
  import type { Logger } from 'pino';
15
15
  import type { InferenceExecutor } from './ipc/inference_executor.js';
16
16
  import { log } from './log.js';
17
+ import { flushOtelLogs, setupCloudTracer, uploadSessionReport } from './telemetry/index.js';
18
+ import { isCloud } from './utils.js';
17
19
  import type { AgentSession } from './voice/agent_session.js';
18
20
  import { type SessionReport, createSessionReport } from './voice/report.js';
19
21
 
@@ -81,8 +83,6 @@ export class FunctionExistsError extends Error {
81
83
  }
82
84
 
83
85
  /** The job and environment context as seen by the agent, accessible by the entrypoint function. */
84
- // TODO(brian): PR3 - Add @tracer.startActiveSpan('job_entrypoint') wrapper in entrypoint
85
- // TODO(brian): PR5 - Add uploadSessionReport() call in cleanup/session end
86
86
  export class JobContext {
87
87
  #proc: JobProcess;
88
88
  #info: RunningJobInfo;
@@ -142,6 +142,10 @@ export class JobContext {
142
142
  return this.#room;
143
143
  }
144
144
 
145
+ get info(): RunningJobInfo {
146
+ return this.#info;
147
+ }
148
+
145
149
  /** @returns The agent's participant if connected to the room, otherwise `undefined` */
146
150
  get agent(): LocalParticipant | undefined {
147
151
  return this.#room.localParticipant;
@@ -247,7 +251,6 @@ export class JobContext {
247
251
  }
248
252
 
249
253
  // TODO(brian): implement and check recorder io
250
- // TODO(brian): PR5 - Ensure chat history serialization includes all required fields (use sessionReportToJSON helper)
251
254
 
252
255
  return createSessionReport({
253
256
  jobId: this.job.id,
@@ -257,6 +260,7 @@ export class JobContext {
257
260
  events: targetSession._recordedEvents,
258
261
  enableUserDataTraining: true,
259
262
  chatHistory: targetSession.history.copy(),
263
+ startedAt: targetSession._startedAt,
260
264
  });
261
265
  }
262
266
 
@@ -270,14 +274,45 @@ export class JobContext {
270
274
 
271
275
  // TODO(brian): Implement CLI/console
272
276
 
273
- // TODO(brian): PR5 - Call uploadSessionReport() if report.enableUserDataTraining is true
274
- // TODO(brian): PR5 - Upload includes: multipart form with header (protobuf), chat_history (JSON), and audio recording (if available)
277
+ // Upload session report to LiveKit Cloud if enabled
278
+ const url = new URL(this.#info.url);
275
279
 
276
- this.#logger.debug('Session ended, report generated', {
277
- jobId: report.jobId,
278
- roomId: report.roomId,
279
- eventsCount: report.events.length,
280
- });
280
+ if (report.enableRecording && isCloud(url)) {
281
+ try {
282
+ await uploadSessionReport({
283
+ agentName: this.job.agentName,
284
+ cloudHostname: url.hostname,
285
+ report,
286
+ });
287
+ this.#logger.info(
288
+ {
289
+ jobId: report.jobId,
290
+ roomId: report.roomId,
291
+ },
292
+ 'Session report uploaded to LiveKit Cloud',
293
+ );
294
+ } catch (error) {
295
+ this.#logger.error({ error }, 'Failed to upload session report');
296
+ }
297
+ }
298
+
299
+ this.#logger.debug(
300
+ {
301
+ jobId: report.jobId,
302
+ roomId: report.roomId,
303
+ eventsCount: report.events.length,
304
+ },
305
+ 'Session ended, report generated',
306
+ );
307
+
308
+ // Explicitly clear the recorded events to avoid leaking memory
309
+ session._recordedEvents = [];
310
+
311
+ try {
312
+ await flushOtelLogs();
313
+ } catch (error) {
314
+ this.#logger.error({ error }, 'Failed to flush OTEL logs');
315
+ }
281
316
  }
282
317
 
283
318
  /**
@@ -316,6 +351,20 @@ export class JobContext {
316
351
 
317
352
  this.#participantEntrypoints.push(callback);
318
353
  }
354
+
355
+ async initRecording() {
356
+ const url = new URL(this.#info.url);
357
+ if (!isCloud(url)) {
358
+ return;
359
+ }
360
+
361
+ this.#logger.debug({ hostname: url.hostname }, 'Configuring session recording (cloud tracer)');
362
+ await setupCloudTracer({
363
+ roomId: this.job.room!.sid,
364
+ jobId: this.job.id,
365
+ cloudHostname: url.hostname,
366
+ });
367
+ }
319
368
  }
320
369
 
321
370
  export class JobProcess {
package/src/llm/llm.ts CHANGED
@@ -2,10 +2,12 @@
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
  import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter';
5
+ import type { Span } from '@opentelemetry/api';
5
6
  import { EventEmitter } from 'node:events';
6
7
  import { APIConnectionError, APIError } from '../_exceptions.js';
7
8
  import { log } from '../log.js';
8
9
  import type { LLMMetrics } from '../metrics/base.js';
10
+ import { recordException, traceTypes, tracer } from '../telemetry/index.js';
9
11
  import type { APIConnectOptions } from '../types.js';
10
12
  import { AsyncIterableQueue, delay, startSoon, toError } from '../utils.js';
11
13
  import { type ChatContext, type ChatRole, type FunctionCall } from './chat_context.js';
@@ -104,6 +106,7 @@ export abstract class LLMStream implements AsyncIterableIterator<ChatChunk> {
104
106
  #llm: LLM;
105
107
  #chatCtx: ChatContext;
106
108
  #toolCtx?: ToolContext;
109
+ #llmRequestSpan?: Span;
107
110
 
108
111
  constructor(
109
112
  llm: LLM,
@@ -135,12 +138,24 @@ export abstract class LLMStream implements AsyncIterableIterator<ChatChunk> {
135
138
  startSoon(() => this.mainTask().then(() => this.queue.close()));
136
139
  }
137
140
 
138
- private async mainTask() {
139
- // TODO(brian): PR3 - Add span wrapping: tracer.startActiveSpan('llm_request', ..., { endOnExit: false })
141
+ private _mainTaskImpl = async (span: Span) => {
142
+ this.#llmRequestSpan = span;
143
+ span.setAttribute(traceTypes.ATTR_GEN_AI_REQUEST_MODEL, this.#llm.model);
144
+
140
145
  for (let i = 0; i < this._connOptions.maxRetry + 1; i++) {
141
146
  try {
142
- // TODO(brian): PR3 - Add span for retry attempts: tracer.startActiveSpan('llm_request_run', ...)
143
- return await this.run();
147
+ return await tracer.startActiveSpan(
148
+ async (attemptSpan) => {
149
+ attemptSpan.setAttribute(traceTypes.ATTR_RETRY_COUNT, i);
150
+ try {
151
+ return await this.run();
152
+ } catch (error) {
153
+ recordException(attemptSpan, toError(error));
154
+ throw error;
155
+ }
156
+ },
157
+ { name: 'llm_request_run' },
158
+ );
144
159
  } catch (error) {
145
160
  if (error instanceof APIError) {
146
161
  const retryInterval = this._connOptions._intervalForRetry(i);
@@ -171,7 +186,13 @@ export abstract class LLMStream implements AsyncIterableIterator<ChatChunk> {
171
186
  }
172
187
  }
173
188
  }
174
- }
189
+ };
190
+
191
+ private mainTask = async () =>
192
+ tracer.startActiveSpan(async (span) => this._mainTaskImpl(span), {
193
+ name: 'llm_request',
194
+ endOnExit: false,
195
+ });
175
196
 
176
197
  private emitError({ error, recoverable }: { error: Error; recoverable: boolean }) {
177
198
  this.#llm.emit('error', {
@@ -188,6 +209,7 @@ export abstract class LLMStream implements AsyncIterableIterator<ChatChunk> {
188
209
  let ttft: bigint = BigInt(-1);
189
210
  let requestId = '';
190
211
  let usage: CompletionUsage | undefined;
212
+ let completionStartTime: string | undefined;
191
213
 
192
214
  for await (const ev of this.queue) {
193
215
  if (this.abortController.signal.aborted) {
@@ -197,6 +219,7 @@ export abstract class LLMStream implements AsyncIterableIterator<ChatChunk> {
197
219
  requestId = ev.id;
198
220
  if (ttft === BigInt(-1)) {
199
221
  ttft = process.hrtime.bigint() - startTime;
222
+ completionStartTime = new Date().toISOString();
200
223
  }
201
224
  if (ev.usage) {
202
225
  usage = ev.usage;
@@ -225,6 +248,26 @@ export abstract class LLMStream implements AsyncIterableIterator<ChatChunk> {
225
248
  return (usage?.completionTokens || 0) / (durationMs / 1000);
226
249
  })(),
227
250
  };
251
+
252
+ if (this.#llmRequestSpan) {
253
+ this.#llmRequestSpan.setAttribute(traceTypes.ATTR_LLM_METRICS, JSON.stringify(metrics));
254
+
255
+ this.#llmRequestSpan.setAttributes({
256
+ [traceTypes.ATTR_GEN_AI_USAGE_INPUT_TOKENS]: metrics.promptTokens,
257
+ [traceTypes.ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: metrics.completionTokens,
258
+ });
259
+
260
+ if (completionStartTime) {
261
+ this.#llmRequestSpan.setAttribute(
262
+ traceTypes.ATTR_LANGFUSE_COMPLETION_START_TIME,
263
+ completionStartTime,
264
+ );
265
+ }
266
+
267
+ // End the span now that metrics are collected
268
+ this.#llmRequestSpan.end();
269
+ }
270
+
228
271
  this.#llm.emit('metrics_collected', metrics);
229
272
  }
230
273
 
package/src/log.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  // SPDX-FileCopyrightText: 2024 LiveKit, Inc.
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
- import type { Logger } from 'pino';
5
- import { pino } from 'pino';
4
+ import { Writable } from 'node:stream';
5
+ import type { DestinationStream, Logger } from 'pino';
6
+ import { multistream, pino } from 'pino';
7
+ import { build as pinoPretty } from 'pino-pretty';
8
+ import { type PinoLogObject, emitToOtel } from './telemetry/pino_otel_transport.js';
6
9
 
7
10
  /** @internal */
8
11
  export type LoggerOptions = {
@@ -16,6 +19,9 @@ export let loggerOptions: LoggerOptions;
16
19
  /** @internal */
17
20
  let logger: Logger | undefined = undefined;
18
21
 
22
+ /** @internal */
23
+ let otelEnabled = false;
24
+
19
25
  /** @internal */
20
26
  export const log = () => {
21
27
  if (!logger) {
@@ -28,19 +34,50 @@ export const log = () => {
28
34
  export const initializeLogger = ({ pretty, level }: LoggerOptions) => {
29
35
  loggerOptions = { pretty, level };
30
36
  logger = pino(
31
- pretty
32
- ? {
33
- transport: {
34
- target: 'pino-pretty',
35
- options: {
36
- colorize: true,
37
- },
38
- },
39
- }
40
- : {},
37
+ { level: level || 'info' },
38
+ pretty ? pinoPretty({ colorize: true }) : process.stdout,
41
39
  );
42
- if (level) {
43
- logger.level = level;
40
+ };
41
+
42
+ /**
43
+ * Custom Pino destination that parses JSON logs and emits to OTEL.
44
+ * This receives the FULL serialized log including msg, level, time, etc.
45
+ */
46
+ class OtelDestination extends Writable {
47
+ _write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void {
48
+ try {
49
+ const line = chunk.toString().trim();
50
+ if (line) {
51
+ const logObj = JSON.parse(line) as PinoLogObject;
52
+ emitToOtel(logObj);
53
+ }
54
+ } catch {
55
+ // Ignore parse errors (e.g., non-JSON lines)
56
+ }
57
+ callback();
44
58
  }
45
- // TODO(brian): PR4 - Add Pino bridge to OTEL LoggingHandler for structured logging integration
59
+ }
60
+
61
+ /**
62
+ * Enable OTEL logging by reconfiguring the logger with multistream.
63
+ * Uses a custom destination that receives full JSON logs (with msg, level, time).
64
+ *
65
+ * @internal
66
+ */
67
+ export const enableOtelLogging = () => {
68
+ if (otelEnabled || !logger) {
69
+ console.warn('OTEL logging already enabled or logger not initialized');
70
+ return;
71
+ }
72
+ otelEnabled = true;
73
+
74
+ const { pretty, level } = loggerOptions;
75
+
76
+ const logLevel = level || 'info';
77
+ const streams: { stream: DestinationStream; level: string }[] = [
78
+ { stream: pretty ? pinoPretty({ colorize: true }) : process.stdout, level: logLevel },
79
+ { stream: new OtelDestination(), level: 'debug' },
80
+ ];
81
+
82
+ logger = pino({ level: logLevel }, multistream(streams));
46
83
  };
package/src/stt/stt.ts CHANGED
@@ -257,7 +257,18 @@ export abstract class SpeechStream implements AsyncIterableIterator<SpeechEvent>
257
257
 
258
258
  protected async monitorMetrics() {
259
259
  for await (const event of this.queue) {
260
- this.output.put(event);
260
+ if (!this.output.closed) {
261
+ try {
262
+ this.output.put(event);
263
+ } catch (e) {
264
+ if (e instanceof Error && e.message.includes('Queue is closed')) {
265
+ this.logger.warn(
266
+ { err: e },
267
+ 'Queue closed during transcript processing (expected during disconnect)',
268
+ );
269
+ }
270
+ }
271
+ }
261
272
  if (event.type !== SpeechEventType.RECOGNITION_USAGE) continue;
262
273
  const metrics: STTMetrics = {
263
274
  type: 'stt_metrics',
@@ -270,7 +281,9 @@ export abstract class SpeechStream implements AsyncIterableIterator<SpeechEvent>
270
281
  };
271
282
  this.#stt.emit('metrics_collected', metrics);
272
283
  }
273
- this.output.close();
284
+ if (!this.output.closed) {
285
+ this.output.close();
286
+ }
274
287
  }
275
288
 
276
289
  protected abstract run(): Promise<void>;
@@ -336,9 +349,9 @@ export abstract class SpeechStream implements AsyncIterableIterator<SpeechEvent>
336
349
 
337
350
  /** Close both the input and output of the STT stream */
338
351
  close() {
339
- this.input.close();
340
- this.queue.close();
341
- this.output.close();
352
+ if (!this.input.closed) this.input.close();
353
+ if (!this.queue.closed) this.queue.close();
354
+ if (!this.output.closed) this.output.close();
342
355
  this.closed = true;
343
356
  }
344
357
 
@@ -2,9 +2,27 @@
2
2
  //
3
3
  // SPDX-License-Identifier: Apache-2.0
4
4
 
5
- // TODO(brian): PR4 - Add logging integration exports
6
- // TODO(brian): PR5 - Add uploadSessionReport export
7
-
5
+ export { ExtraDetailsProcessor, MetadataLogProcessor } from './logging.js';
6
+ export {
7
+ SimpleOTLPHttpLogExporter,
8
+ type SimpleLogRecord,
9
+ type SimpleOTLPHttpLogExporterConfig,
10
+ } from './otel_http_exporter.js';
11
+ export {
12
+ emitToOtel,
13
+ flushPinoLogs,
14
+ initPinoCloudExporter,
15
+ PinoCloudExporter,
16
+ type PinoCloudExporterConfig,
17
+ type PinoLogObject,
18
+ } from './pino_otel_transport.js';
8
19
  export * as traceTypes from './trace_types.js';
9
- export { setTracerProvider, setupCloudTracer, tracer, type StartSpanOptions } from './traces.js';
20
+ export {
21
+ flushOtelLogs,
22
+ setTracerProvider,
23
+ setupCloudTracer,
24
+ tracer,
25
+ uploadSessionReport,
26
+ type StartSpanOptions,
27
+ } from './traces.js';
10
28
  export { recordException, recordRealtimeMetrics } from './utils.js';
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import type { Attributes } from '@opentelemetry/api';
5
+ import type { LogRecord, LogRecordProcessor } from '@opentelemetry/sdk-logs';
6
+
7
+ /**
8
+ * Metadata log processor that injects metadata (room_id, job_id) into all log records.
9
+ */
10
+ export class MetadataLogProcessor implements LogRecordProcessor {
11
+ private metadata: Attributes;
12
+
13
+ constructor(metadata: Attributes) {
14
+ this.metadata = metadata;
15
+ }
16
+
17
+ onEmit(logRecord: LogRecord): void {
18
+ // Add metadata to log record attributes
19
+ if (logRecord.attributes) {
20
+ Object.assign(logRecord.attributes, this.metadata);
21
+ } else {
22
+ (logRecord as any).attributes = { ...this.metadata };
23
+ }
24
+ }
25
+
26
+ shutdown(): Promise<void> {
27
+ return Promise.resolve();
28
+ }
29
+
30
+ forceFlush(): Promise<void> {
31
+ return Promise.resolve();
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Extra details processor that adds logger name to log records.
37
+ */
38
+ export class ExtraDetailsProcessor implements LogRecordProcessor {
39
+ onEmit(logRecord: LogRecord): void {
40
+ const loggerName = logRecord.instrumentationScope.name;
41
+ if (logRecord.attributes) {
42
+ (logRecord.attributes as any)['logger.name'] = loggerName;
43
+ } else {
44
+ (logRecord as any).attributes = { 'logger.name': loggerName };
45
+ }
46
+ }
47
+
48
+ shutdown(): Promise<void> {
49
+ return Promise.resolve();
50
+ }
51
+
52
+ forceFlush(): Promise<void> {
53
+ return Promise.resolve();
54
+ }
55
+ }
@@ -0,0 +1,191 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ /**
6
+ * OTLP HTTP JSON Log Exporter for LiveKit Cloud
7
+ *
8
+ * This module provides a custom OTLP log exporter that uses HTTP with JSON format
9
+ * instead of the default protobuf format.
10
+ */
11
+ import { SeverityNumber } from '@opentelemetry/api-logs';
12
+ import { AccessToken } from 'livekit-server-sdk';
13
+
14
+ export interface SimpleLogRecord {
15
+ /** Log message body */
16
+ body: string;
17
+ /** Timestamp in milliseconds since epoch */
18
+ timestampMs: number;
19
+ /** Log attributes */
20
+ attributes: Record<string, unknown>;
21
+ /** Severity number (default: UNSPECIFIED) */
22
+ severityNumber?: SeverityNumber;
23
+ /** Severity text (default: 'unspecified') */
24
+ severityText?: string;
25
+ }
26
+
27
+ export interface SimpleOTLPHttpLogExporterConfig {
28
+ /** LiveKit Cloud hostname */
29
+ cloudHostname: string;
30
+ /** Resource attributes (e.g., room_id, job_id) */
31
+ resourceAttributes: Record<string, string>;
32
+ /** Scope name for the logger */
33
+ scopeName: string;
34
+ /** Scope attributes */
35
+ scopeAttributes?: Record<string, string>;
36
+ }
37
+
38
+ /**
39
+ * Simple OTLP HTTP Log Exporter for direct log export
40
+ *
41
+ * This is a simplified exporter that doesn't require the full SDK infrastructure.
42
+ * Use this when you need to send logs directly without LoggerProvider.
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * const exporter = new SimpleOTLPHttpLogExporter({
47
+ * cloudHostname: 'cloud.livekit.io',
48
+ * resourceAttributes: { room_id: 'xxx', job_id: 'yyy' },
49
+ * scopeName: 'chat_history',
50
+ * });
51
+ *
52
+ * await exporter.export([
53
+ * { body: 'Hello', timestampMs: Date.now(), attributes: { test: true } },
54
+ * ]);
55
+ * ```
56
+ */
57
+ export class SimpleOTLPHttpLogExporter {
58
+ private readonly config: SimpleOTLPHttpLogExporterConfig;
59
+ private jwt: string | null = null;
60
+
61
+ constructor(config: SimpleOTLPHttpLogExporterConfig) {
62
+ this.config = config;
63
+ }
64
+
65
+ /**
66
+ * Export simple log records
67
+ */
68
+ async export(records: SimpleLogRecord[]): Promise<void> {
69
+ if (records.length === 0) return;
70
+
71
+ await this.ensureJwt();
72
+
73
+ const endpoint = `https://${this.config.cloudHostname}/observability/logs/otlp/v0`;
74
+ const payload = this.buildPayload(records);
75
+
76
+ const response = await fetch(endpoint, {
77
+ method: 'POST',
78
+ headers: {
79
+ Authorization: `Bearer ${this.jwt}`,
80
+ 'Content-Type': 'application/json',
81
+ },
82
+ body: JSON.stringify(payload),
83
+ });
84
+
85
+ if (!response.ok) {
86
+ const text = await response.text();
87
+ throw new Error(
88
+ `OTLP log export failed: ${response.status} ${response.statusText} - ${text}`,
89
+ );
90
+ }
91
+ }
92
+
93
+ private async ensureJwt(): Promise<void> {
94
+ if (this.jwt) return;
95
+
96
+ const apiKey = process.env.LIVEKIT_API_KEY;
97
+ const apiSecret = process.env.LIVEKIT_API_SECRET;
98
+
99
+ if (!apiKey || !apiSecret) {
100
+ throw new Error('LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set');
101
+ }
102
+
103
+ const token = new AccessToken(apiKey, apiSecret, { ttl: '6h' });
104
+ token.addObservabilityGrant({ write: true });
105
+ this.jwt = await token.toJwt();
106
+ }
107
+
108
+ private buildPayload(records: SimpleLogRecord[]): object {
109
+ const resourceAttrs = Object.entries(this.config.resourceAttributes).map(([key, value]) => ({
110
+ key,
111
+ value: { stringValue: value },
112
+ }));
113
+
114
+ if (!this.config.resourceAttributes['service.name']) {
115
+ resourceAttrs.push({ key: 'service.name', value: { stringValue: 'livekit-agents' } });
116
+ }
117
+
118
+ const scopeAttrs = this.config.scopeAttributes
119
+ ? Object.entries(this.config.scopeAttributes).map(([key, value]) => ({
120
+ key,
121
+ value: { stringValue: value },
122
+ }))
123
+ : [];
124
+
125
+ const logRecords = records.map((record) => ({
126
+ timeUnixNano: String(BigInt(Math.floor(record.timestampMs * 1_000_000))),
127
+ observedTimeUnixNano: String(BigInt(Date.now()) * BigInt(1_000_000)),
128
+ severityNumber: record.severityNumber ?? SeverityNumber.UNSPECIFIED,
129
+ severityText: record.severityText ?? 'unspecified',
130
+ body: { stringValue: record.body },
131
+ attributes: this.convertAttributes(record.attributes),
132
+ traceId: '',
133
+ spanId: '',
134
+ }));
135
+
136
+ return {
137
+ resourceLogs: [
138
+ {
139
+ resource: { attributes: resourceAttrs },
140
+ scopeLogs: [
141
+ {
142
+ scope: {
143
+ name: this.config.scopeName,
144
+ attributes: scopeAttrs,
145
+ },
146
+ logRecords,
147
+ },
148
+ ],
149
+ },
150
+ ],
151
+ };
152
+ }
153
+
154
+ private convertAttributes(
155
+ attrs: Record<string, unknown>,
156
+ ): Array<{ key: string; value: unknown }> {
157
+ return Object.entries(attrs).map(([key, value]) => ({
158
+ key,
159
+ value: this.convertValue(value),
160
+ }));
161
+ }
162
+
163
+ private convertValue(value: unknown): unknown {
164
+ if (value === null || value === undefined) {
165
+ return { stringValue: '' };
166
+ }
167
+ if (typeof value === 'string') {
168
+ return { stringValue: value };
169
+ }
170
+ if (typeof value === 'number') {
171
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
172
+ }
173
+ if (typeof value === 'boolean') {
174
+ return { boolValue: value };
175
+ }
176
+ if (Array.isArray(value)) {
177
+ return { arrayValue: { values: value.map((v) => this.convertValue(v)) } };
178
+ }
179
+ if (typeof value === 'object') {
180
+ return {
181
+ kvlistValue: {
182
+ values: Object.entries(value as Record<string, unknown>).map(([k, v]) => ({
183
+ key: k,
184
+ value: this.convertValue(v),
185
+ })),
186
+ },
187
+ };
188
+ }
189
+ return { stringValue: String(value) };
190
+ }
191
+ }