@livekit/agents 1.0.22 → 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 (131) 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/ipc/job_proc_lazy_main.cjs +35 -1
  8. package/dist/ipc/job_proc_lazy_main.cjs.map +1 -1
  9. package/dist/ipc/job_proc_lazy_main.js +13 -1
  10. package/dist/ipc/job_proc_lazy_main.js.map +1 -1
  11. package/dist/job.cjs +52 -6
  12. package/dist/job.cjs.map +1 -1
  13. package/dist/job.d.cts +2 -0
  14. package/dist/job.d.ts +2 -0
  15. package/dist/job.d.ts.map +1 -1
  16. package/dist/job.js +52 -6
  17. package/dist/job.js.map +1 -1
  18. package/dist/llm/llm.cjs +38 -3
  19. package/dist/llm/llm.cjs.map +1 -1
  20. package/dist/llm/llm.d.cts +1 -0
  21. package/dist/llm/llm.d.ts +1 -0
  22. package/dist/llm/llm.d.ts.map +1 -1
  23. package/dist/llm/llm.js +38 -3
  24. package/dist/llm/llm.js.map +1 -1
  25. package/dist/log.cjs +34 -10
  26. package/dist/log.cjs.map +1 -1
  27. package/dist/log.d.cts +7 -0
  28. package/dist/log.d.ts +7 -0
  29. package/dist/log.d.ts.map +1 -1
  30. package/dist/log.js +34 -11
  31. package/dist/log.js.map +1 -1
  32. package/dist/telemetry/index.cjs +23 -2
  33. package/dist/telemetry/index.cjs.map +1 -1
  34. package/dist/telemetry/index.d.cts +4 -1
  35. package/dist/telemetry/index.d.ts +4 -1
  36. package/dist/telemetry/index.d.ts.map +1 -1
  37. package/dist/telemetry/index.js +27 -2
  38. package/dist/telemetry/index.js.map +1 -1
  39. package/dist/telemetry/logging.cjs +65 -0
  40. package/dist/telemetry/logging.cjs.map +1 -0
  41. package/dist/telemetry/logging.d.cts +21 -0
  42. package/dist/telemetry/logging.d.ts +21 -0
  43. package/dist/telemetry/logging.d.ts.map +1 -0
  44. package/dist/telemetry/logging.js +40 -0
  45. package/dist/telemetry/logging.js.map +1 -0
  46. package/dist/telemetry/otel_http_exporter.cjs +144 -0
  47. package/dist/telemetry/otel_http_exporter.cjs.map +1 -0
  48. package/dist/telemetry/otel_http_exporter.d.cts +62 -0
  49. package/dist/telemetry/otel_http_exporter.d.ts +62 -0
  50. package/dist/telemetry/otel_http_exporter.d.ts.map +1 -0
  51. package/dist/telemetry/otel_http_exporter.js +120 -0
  52. package/dist/telemetry/otel_http_exporter.js.map +1 -0
  53. package/dist/telemetry/pino_otel_transport.cjs +217 -0
  54. package/dist/telemetry/pino_otel_transport.cjs.map +1 -0
  55. package/dist/telemetry/pino_otel_transport.d.cts +58 -0
  56. package/dist/telemetry/pino_otel_transport.d.ts +58 -0
  57. package/dist/telemetry/pino_otel_transport.d.ts.map +1 -0
  58. package/dist/telemetry/pino_otel_transport.js +189 -0
  59. package/dist/telemetry/pino_otel_transport.js.map +1 -0
  60. package/dist/telemetry/traces.cjs +225 -16
  61. package/dist/telemetry/traces.cjs.map +1 -1
  62. package/dist/telemetry/traces.d.cts +17 -0
  63. package/dist/telemetry/traces.d.ts +17 -0
  64. package/dist/telemetry/traces.d.ts.map +1 -1
  65. package/dist/telemetry/traces.js +211 -14
  66. package/dist/telemetry/traces.js.map +1 -1
  67. package/dist/tts/tts.cjs +62 -5
  68. package/dist/tts/tts.cjs.map +1 -1
  69. package/dist/tts/tts.d.cts +2 -0
  70. package/dist/tts/tts.d.ts +2 -0
  71. package/dist/tts/tts.d.ts.map +1 -1
  72. package/dist/tts/tts.js +62 -5
  73. package/dist/tts/tts.js.map +1 -1
  74. package/dist/utils.cjs +6 -0
  75. package/dist/utils.cjs.map +1 -1
  76. package/dist/utils.d.cts +1 -0
  77. package/dist/utils.d.ts +1 -0
  78. package/dist/utils.d.ts.map +1 -1
  79. package/dist/utils.js +5 -0
  80. package/dist/utils.js.map +1 -1
  81. package/dist/voice/agent_activity.cjs +93 -7
  82. package/dist/voice/agent_activity.cjs.map +1 -1
  83. package/dist/voice/agent_activity.d.cts +3 -0
  84. package/dist/voice/agent_activity.d.ts +3 -0
  85. package/dist/voice/agent_activity.d.ts.map +1 -1
  86. package/dist/voice/agent_activity.js +93 -7
  87. package/dist/voice/agent_activity.js.map +1 -1
  88. package/dist/voice/agent_session.cjs +122 -27
  89. package/dist/voice/agent_session.cjs.map +1 -1
  90. package/dist/voice/agent_session.d.cts +15 -0
  91. package/dist/voice/agent_session.d.ts +15 -0
  92. package/dist/voice/agent_session.d.ts.map +1 -1
  93. package/dist/voice/agent_session.js +122 -27
  94. package/dist/voice/agent_session.js.map +1 -1
  95. package/dist/voice/audio_recognition.cjs +69 -22
  96. package/dist/voice/audio_recognition.cjs.map +1 -1
  97. package/dist/voice/audio_recognition.d.cts +5 -0
  98. package/dist/voice/audio_recognition.d.ts +5 -0
  99. package/dist/voice/audio_recognition.d.ts.map +1 -1
  100. package/dist/voice/audio_recognition.js +69 -22
  101. package/dist/voice/audio_recognition.js.map +1 -1
  102. package/dist/voice/generation.cjs +43 -3
  103. package/dist/voice/generation.cjs.map +1 -1
  104. package/dist/voice/generation.d.ts.map +1 -1
  105. package/dist/voice/generation.js +43 -3
  106. package/dist/voice/generation.js.map +1 -1
  107. package/dist/voice/report.cjs +3 -2
  108. package/dist/voice/report.cjs.map +1 -1
  109. package/dist/voice/report.d.cts +7 -1
  110. package/dist/voice/report.d.ts +7 -1
  111. package/dist/voice/report.d.ts.map +1 -1
  112. package/dist/voice/report.js +3 -2
  113. package/dist/voice/report.js.map +1 -1
  114. package/package.json +8 -2
  115. package/src/inference/api_protos.ts +2 -2
  116. package/src/ipc/job_proc_lazy_main.ts +12 -1
  117. package/src/job.ts +59 -10
  118. package/src/llm/llm.ts +48 -5
  119. package/src/log.ts +52 -15
  120. package/src/telemetry/index.ts +22 -4
  121. package/src/telemetry/logging.ts +55 -0
  122. package/src/telemetry/otel_http_exporter.ts +191 -0
  123. package/src/telemetry/pino_otel_transport.ts +265 -0
  124. package/src/telemetry/traces.ts +320 -20
  125. package/src/tts/tts.ts +71 -9
  126. package/src/utils.ts +5 -0
  127. package/src/voice/agent_activity.ts +140 -22
  128. package/src/voice/agent_session.ts +174 -34
  129. package/src/voice/audio_recognition.ts +85 -26
  130. package/src/voice/generation.ts +59 -7
  131. package/src/voice/report.ts +10 -4
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
  };
@@ -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
+ }
@@ -0,0 +1,265 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+
5
+ /**
6
+ * Custom Pino OTEL Transport
7
+ *
8
+ * Standalone exporter for Pino logs to LiveKit Cloud.
9
+ * Uses raw HTTP JSON format, bypassing the OTEL SDK.
10
+ */
11
+ import { SeverityNumber } from '@opentelemetry/api-logs';
12
+ import { AccessToken } from 'livekit-server-sdk';
13
+
14
+ export interface PinoLogObject {
15
+ level: number;
16
+ time: number;
17
+ msg: string;
18
+ pid?: number;
19
+ hostname?: string;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ export interface PinoCloudExporterConfig {
24
+ cloudHostname: string;
25
+ roomId: string;
26
+ jobId: string;
27
+ loggerName?: string;
28
+ batchSize?: number;
29
+ flushIntervalMs?: number;
30
+ }
31
+
32
+ function mapPinoLevelToSeverity(pinoLevel: number): {
33
+ severityNumber: SeverityNumber;
34
+ severityText: string;
35
+ } {
36
+ if (pinoLevel <= 10) {
37
+ return { severityNumber: SeverityNumber.TRACE, severityText: 'TRACE' };
38
+ } else if (pinoLevel <= 20) {
39
+ return { severityNumber: SeverityNumber.DEBUG, severityText: 'DEBUG' };
40
+ } else if (pinoLevel <= 30) {
41
+ return { severityNumber: SeverityNumber.INFO, severityText: 'INFO' };
42
+ } else if (pinoLevel <= 40) {
43
+ return { severityNumber: SeverityNumber.WARN, severityText: 'WARN' };
44
+ } else if (pinoLevel <= 50) {
45
+ return { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' };
46
+ } else {
47
+ return { severityNumber: SeverityNumber.FATAL, severityText: 'FATAL' };
48
+ }
49
+ }
50
+
51
+ const EXCLUDE_FIELDS = new Set(['level', 'time', 'msg', 'pid', 'hostname', 'v']);
52
+
53
+ function convertValue(value: unknown): unknown {
54
+ if (value === null || value === undefined) {
55
+ return { stringValue: '' };
56
+ }
57
+ if (typeof value === 'string') {
58
+ return { stringValue: value };
59
+ }
60
+ if (typeof value === 'number') {
61
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
62
+ }
63
+ if (typeof value === 'boolean') {
64
+ return { boolValue: value };
65
+ }
66
+ if (typeof value === 'object') {
67
+ return { stringValue: JSON.stringify(value) };
68
+ }
69
+ return { stringValue: String(value) };
70
+ }
71
+
72
+ /**
73
+ * Standalone Pino log exporter for LiveKit Cloud.
74
+ *
75
+ * Collects Pino logs, batches them, and sends via raw HTTP JSON.
76
+ * No OTEL SDK dependency
77
+ *
78
+ * @example
79
+ * ```typescript
80
+ * const exporter = new PinoCloudExporter({
81
+ * cloudHostname: 'cloud.livekit.io',
82
+ * roomId: 'RM_xxx',
83
+ * jobId: 'AJ_xxx',
84
+ * });
85
+ *
86
+ * // In Pino formatter hook:
87
+ * exporter.emit(logObj);
88
+ *
89
+ * // On session end:
90
+ * await exporter.flush();
91
+ * ```
92
+ */
93
+ export class PinoCloudExporter {
94
+ private readonly config: PinoCloudExporterConfig;
95
+ private readonly loggerName: string;
96
+ private readonly batchSize: number;
97
+ private readonly flushIntervalMs: number;
98
+ private jwt: string | null = null;
99
+ private pendingLogs: any[] = [];
100
+ private flushTimer: NodeJS.Timeout | null = null;
101
+
102
+ constructor(config: PinoCloudExporterConfig) {
103
+ this.config = config;
104
+ this.loggerName = config.loggerName || 'livekit.agents';
105
+ this.batchSize = config.batchSize || 100;
106
+ this.flushIntervalMs = config.flushIntervalMs || 5000;
107
+ }
108
+
109
+ emit(logObj: PinoLogObject): void {
110
+ const record = this.convertToOtlpRecord(logObj);
111
+ this.pendingLogs.push(record);
112
+
113
+ if (!this.flushTimer) {
114
+ this.flushTimer = setTimeout(() => {
115
+ this.flush().catch(console.error);
116
+ }, this.flushIntervalMs);
117
+ }
118
+
119
+ if (this.pendingLogs.length >= this.batchSize) {
120
+ this.flush().catch(console.error);
121
+ }
122
+ }
123
+
124
+ private convertToOtlpRecord(logObj: PinoLogObject): any {
125
+ const { severityNumber, severityText } = mapPinoLevelToSeverity(logObj.level);
126
+
127
+ const attributes: any[] = [
128
+ { key: 'room_id', value: { stringValue: this.config.roomId } },
129
+ { key: 'job_id', value: { stringValue: this.config.jobId } },
130
+ { key: 'logger.name', value: { stringValue: this.loggerName } },
131
+ ];
132
+
133
+ if (logObj.pid !== undefined) {
134
+ attributes.push({ key: 'process.pid', value: { intValue: String(logObj.pid) } });
135
+ }
136
+ if (logObj.hostname !== undefined) {
137
+ attributes.push({ key: 'host.name', value: { stringValue: logObj.hostname } });
138
+ }
139
+
140
+ for (const [key, value] of Object.entries(logObj)) {
141
+ if (!EXCLUDE_FIELDS.has(key)) {
142
+ attributes.push({ key, value: convertValue(value) });
143
+ }
144
+ }
145
+
146
+ return {
147
+ timeUnixNano: String(BigInt(logObj.time) * BigInt(1_000_000)),
148
+ observedTimeUnixNano: String(BigInt(Date.now()) * BigInt(1_000_000)),
149
+ severityNumber,
150
+ severityText,
151
+ body: { stringValue: logObj.msg || '' },
152
+ attributes,
153
+ traceId: '',
154
+ spanId: '',
155
+ };
156
+ }
157
+
158
+ async flush(): Promise<void> {
159
+ if (this.flushTimer) {
160
+ clearTimeout(this.flushTimer);
161
+ this.flushTimer = null;
162
+ }
163
+
164
+ if (this.pendingLogs.length === 0) {
165
+ return;
166
+ }
167
+
168
+ const logs = this.pendingLogs;
169
+ this.pendingLogs = [];
170
+
171
+ try {
172
+ await this.sendLogs(logs);
173
+ } catch (error) {
174
+ this.pendingLogs = [...logs, ...this.pendingLogs];
175
+ console.error('[PinoCloudExporter] Failed to flush logs:', error);
176
+ }
177
+ }
178
+
179
+ private async sendLogs(logRecords: any[]): Promise<void> {
180
+ await this.ensureJwt();
181
+
182
+ const payload = {
183
+ resourceLogs: [
184
+ {
185
+ resource: {
186
+ attributes: [
187
+ { key: 'service.name', value: { stringValue: 'livekit-agents' } },
188
+ { key: 'room_id', value: { stringValue: this.config.roomId } },
189
+ { key: 'job_id', value: { stringValue: this.config.jobId } },
190
+ ],
191
+ },
192
+ scopeLogs: [
193
+ {
194
+ scope: {
195
+ name: this.loggerName,
196
+ attributes: [
197
+ { key: 'room_id', value: { stringValue: this.config.roomId } },
198
+ { key: 'job_id', value: { stringValue: this.config.jobId } },
199
+ ],
200
+ },
201
+ logRecords,
202
+ },
203
+ ],
204
+ },
205
+ ],
206
+ };
207
+
208
+ const endpoint = `https://${this.config.cloudHostname}/observability/logs/otlp/v0`;
209
+
210
+ const response = await fetch(endpoint, {
211
+ method: 'POST',
212
+ headers: {
213
+ Authorization: `Bearer ${this.jwt}`,
214
+ 'Content-Type': 'application/json',
215
+ },
216
+ body: JSON.stringify(payload),
217
+ });
218
+
219
+ if (!response.ok) {
220
+ const text = await response.text();
221
+ throw new Error(`Log export failed: ${response.status} ${response.statusText} - ${text}`);
222
+ }
223
+ }
224
+
225
+ private async ensureJwt(): Promise<void> {
226
+ if (this.jwt) return;
227
+
228
+ const apiKey = process.env.LIVEKIT_API_KEY;
229
+ const apiSecret = process.env.LIVEKIT_API_SECRET;
230
+
231
+ if (!apiKey || !apiSecret) {
232
+ throw new Error('LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set');
233
+ }
234
+
235
+ const token = new AccessToken(apiKey, apiSecret, { ttl: '6h' });
236
+ token.addObservabilityGrant({ write: true });
237
+ this.jwt = await token.toJwt();
238
+ }
239
+
240
+ async shutdown(): Promise<void> {
241
+ await this.flush();
242
+ }
243
+ }
244
+
245
+ let globalExporter: PinoCloudExporter | null = null;
246
+
247
+ export function initPinoCloudExporter(config: PinoCloudExporterConfig): void {
248
+ globalExporter = new PinoCloudExporter(config);
249
+ }
250
+
251
+ export function getPinoCloudExporter(): PinoCloudExporter | null {
252
+ return globalExporter;
253
+ }
254
+
255
+ export function emitToOtel(logObj: PinoLogObject): void {
256
+ if (globalExporter) {
257
+ globalExporter.emit(logObj);
258
+ }
259
+ }
260
+
261
+ export async function flushPinoLogs(): Promise<void> {
262
+ if (globalExporter) {
263
+ await globalExporter.flush();
264
+ }
265
+ }