Skip to content

Commit 33ca7ad

Browse files
authored
Upload Transcripts using HTTP Logger Exporter (#863)
1 parent 90728d7 commit 33ca7ad

File tree

12 files changed

+364
-95
lines changed

12 files changed

+364
-95
lines changed

.changeset/gentle-areas-float.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': patch
3+
---
4+
5+
Support transcripts & traces upload to livekit cloud observability

agents/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@livekit/typed-emitter": "^3.0.0",
5353
"@opentelemetry/api": "^1.9.0",
5454
"@opentelemetry/api-logs": "^0.54.0",
55+
"@opentelemetry/core": "^2.2.0",
5556
"@opentelemetry/exporter-logs-otlp-http": "^0.54.0",
5657
"@opentelemetry/exporter-trace-otlp-http": "^0.54.0",
5758
"@opentelemetry/instrumentation-pino": "^0.43.0",

agents/src/job.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export class JobContext {
260260
events: targetSession._recordedEvents,
261261
enableUserDataTraining: true,
262262
chatHistory: targetSession.history.copy(),
263+
startedAt: targetSession._startedAt,
263264
});
264265
}
265266

agents/src/telemetry/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5-
// TODO(brian): PR5 - Add uploadSessionReport export
6-
75
export { ExtraDetailsProcessor, MetadataLogProcessor } from './logging.js';
6+
export {
7+
SimpleOTLPHttpLogExporter,
8+
type SimpleLogRecord,
9+
type SimpleOTLPHttpLogExporterConfig,
10+
} from './otel_http_exporter.js';
811
export { enablePinoOTELInstrumentation } from './pino_bridge.js';
912
export * as traceTypes from './trace_types.js';
1013
export {
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)