Package
Logthread for LiveKit Agents (Node.js)
6f0ca458f502… One line in your agent entrypoint sends the agent's OpenTelemetry traces, usage and latency metrics, and correlated logs to Logthread. Everything goes to your Logthread endpoint with your Logthread ingest key. It never throws into your agent.
Install#
npm install https://downloads.logthread.com/sdk/node/<version>/logthread-node-<version>.tgz \
https://downloads.logthread.com/sdk/livekit-agents-node/<version>/logthread-livekit-agents-<version>.tgz \
@opentelemetry/apiThe packages are hosted by Logthread, not on npm yet, so install the logs SDK (@logthread/node) and this helper together as above. The exact commands, sizes and checksums are on the download page.
- Peers:
@livekit/agents1.x (your agent already has it) and@opentelemetry/api^1.9. - Dependencies:
@logthread/node(logs),@opentelemetry/sdk-trace-base,sdk-metrics,resources,core,context-async-hooks,exporter-trace-otlp-http,exporter-metrics-otlp-http(OpenTelemetry JS SDK 2.x). The exporters send OTLP/HTTP JSON, which the Logthread gateway accepts. - Node 18 or newer. ESM and CommonJS.
Quick start#
export LOGTHREAD_ENDPOINT=https://ingest.example.com # shown in Logthread onboarding
export LOGTHREAD_INGEST_KEY=<your ingest key>import { instrument } from '@logthread/livekit-agents';
export default defineAgent({
entry: async (ctx: JobContext) => {
const lt = instrument(ctx); // reads LOGTHREAD_ENDPOINT / LOGTHREAD_INGEST_KEY
const session = new voice.AgentSession({ /* ... */ });
lt.watch(session); // usage + latency metrics + session report
// ...
lt.logger.info('ready'); // optional: correlated app logs
},
});A runnable agent is in examples/agent.mjs.
instrument returns synchronously. By default it imports @livekit/agents itself to reach telemetry; that import resolves in the next tick (the module is already loaded by your agent), and await lt.ready waits for it if you want the trace wiring finished before session.start. To make setup fully synchronous (and avoid loading a second copy of the package in mixed ESM/CJS apps), pass your own import: instrument(ctx, { telemetry }) with import { telemetry } from '@livekit/agents'.
What it sends#
| signal | what | where |
|---|---|---|
| Traces | LiveKit's agent spans (lk.* and gen_ai.* attributes), plus livekit.room.sid, livekit.room.name, livekit.job.id, livekit.agent.name, livekit.worker.id | OTLP/HTTP to {endpoint}/v1/traces, Authorization: Bearer <key> |
| Metrics | the instruments below, cumulative, exported every 60 s and at shutdown | OTLP/HTTP to {endpoint}/v1/metrics |
| Logs | what you log through lt.logger, plus the agent session started, agent session error and session report records, with correlation_id and livekit.* attributes | {endpoint}/v1/logs |
Metrics (attribute values are strings):
| name | type | attributes |
|---|---|---|
livekit.agent.llm.tokens | counter ({token}) | provider, model, direction (input/output), cached (true/false) |
livekit.agent.tts.audio_seconds | counter (s) | provider, model |
livekit.agent.stt.audio_seconds | counter (s) | provider, model |
livekit.agent.llm.ttft_ms | histogram (ms) | provider, model |
livekit.agent.tts.ttfb_ms | histogram (ms) | provider, model |
livekit.agent.stt.duration_ms | histogram (ms) | provider, model (non-streaming STT only) |
livekit.agent.eou.delay_ms | histogram (ms) | none |
livekit.agent.e2e_latency_ms | histogram (ms) | none (per assistant turn) |
livekit.agent.sessions | counter | state (started/ended) |
livekit.agent.interruptions | counter | none |
Token counters are cumulative and monotonic, fed with the difference between each session_usage_updated value and the previous one for the same model. If metrics_collected arrives first, the usage it already counted becomes the baseline when the first usage event arrives, so nothing is counted twice. input with cached=true are cache reads; cached=false is input minus cache reads.
Correlation. correlation_id is the room session id (RM_...), and the job id until the sid is known. The sid comes from ctx.job.room.sid (set before the entrypoint runs); if empty it is resolved with Room.getSid() on the room's roomSidChanged, connectionStateChanged and reconnected events. lt.logger records, span exports and metric stamps use the latest value. The async-local id that @logthread/node uses for console/pino capture is set (through @logthread/node's public correlation API) to the id known at instrument() time for the rest of the entrypoint's async chain, and lt.run(fn) runs a function with the current id.
Privacy. PII is off by default.
- Spans:
allowPii: falseis passed to LiveKit'ssetTracerProvider, and enforced again on the Logthread exporter, which drops everylk.pii.*attribute, the GenAI content attributes (gen_ai.input.messages,gen_ai.output.messages,gen_ai.system_instructions,gen_ai.tool.*,gen_ai.prompt.variable.*) andgen_ai.*message events, and redacts exception messages and stack traces. This also holds when the application shares its own provider. It does not change what LiveKit Cloud receives (the project's PII setting). lt.loggerremoveslk.pii.*keys from the attributes you pass.- The session report has no transcript unless
includeTranscript: true.
Configuration#
instrument(ctx, options); explicit options win over the environment.
| option | env | default | notes |
|---|---|---|---|
endpoint | LOGTHREAD_ENDPOINT | none | ingest base URL; /v1/... is appended. HTTPS required unless the host is localhost, 127.0.0.1, ::1 or *.localhost, or allowInsecure |
apiKey | LOGTHREAD_INGEST_KEY | none | sent as Authorization: Bearer <key>; never logged |
service | LOGTHREAD_SERVICE | agent name, else livekit-agent | service.name |
environment | LOGTHREAD_ENVIRONMENT | none | deployment.environment |
allowPii | false | let Logthread receive conversation content in spans | |
includeTranscript | false | put user/assistant text in the session report record | |
traces, metrics, logs | LOGTHREAD_LIVEKIT_NO_TRACES, ..._NO_METRICS, ..._NO_LOGS set to true | all on | switch a signal off |
metricsIntervalMs | 60000 | metric export interval | |
shutdownTimeoutMs | 5000 | bound for flush/shutdown, capped at 5000 | |
attributes | none | extra attributes on every lt.logger record | |
allowInsecure | LOGTHREAD_ALLOW_INSECURE | false | allow plain http to a non-local host |
debug | LOGTHREAD_DEBUG | false | one stderr line per problem |
telemetry | imported | your telemetry from @livekit/agents | |
tracerProvider, registerSpanProcessor | none | share your own provider (below) | |
LOGTHREAD_DISABLED=true | kill switch with no code change |
With no key or endpoint the helper disables itself, prints one stderr line and the agent runs on.
The handle: lt.watch(session), lt.logger, lt.run(fn), await lt.flush(), await lt.shutdown(), lt.correlationId, lt.enabled, lt.ready. You do not need to call shutdown: instrument registers ctx.addShutdownCallback, which logs the session report and flushes traces, metrics and logs, bounded at 5 s (a hung exporter cannot hold the agent).
Sharing your tracer provider. OpenTelemetry JS 2.x providers take their processors at construction. If you already built one, give it a FanoutSpanProcessor and pass it along; Logthread is added as one more processor and nothing is replaced:
const fanout = new telemetry.FanoutSpanProcessor();
const provider = new NodeTracerProvider({ spanProcessors: [new BatchSpanProcessor(myExporter), fanout] });
telemetry.setTracerProvider(provider, { registerSpanProcessor: (p) => fanout.add(p) });
const lt = instrument(ctx, { telemetry, tracerProvider: provider, registerSpanProcessor: (p) => fanout.add(p) });Without registerSpanProcessor, an existing 2.x provider is extended on a best-effort basis through its internal processor list; if that is not possible, traces stay off with one debug line and your provider is left alone.
Compatibility#
Supported: @livekit/agents 1.x, OpenTelemetry JS SDK 2.x. When an API is missing the affected feature is switched off with one debug line (debug: true): no telemetry.setTracerProvider means no traces; no session_usage_updated events means usage is derived from metrics_collected; a session.on that throws skips that subscription.
Verified against the LiveKit docs MCP on 2026-10-18 (docs and livekit/agents-js, livekit/node-sdks main; @livekit/agents 1.9.3):
telemetry.setTracerProvider(provider, { metadata, registerSpanProcessor, createCloudSpanProcessor, allowPii })andtelemetry.FanoutSpanProcessor(add(processor)):agents/src/telemetry/traces.ts; docs page/testing/observability/tracing(Node.js tab, "Strip PII from exported traces").registerSpanProcessoralso keeps LiveKit Cloud's exporter on the same provider.JobContext:job.id,job.agentName,job.room?.sid,job.room?.name,workerId,room,addShutdownCallback(callback),makeSessionReport(session?):agents/src/job.ts.Room.getSid()(resolves after connect,''when not connected) andRoomEventvaluesroomSidChanged,connectionStateChanged,reconnected:packages/livekit-rtc/src/room.ts(node-sdks).RoomEvent.Connectedexists but the room does not emit it, so it is not used.- Session events:
AgentSessionEventTypesincludesmetrics_collectedandsession_usage_updated(createSessionUsageUpdatedEvent({ usage }),usage.modelUsage),conversation_item_added,error,close:agents/src/voice/events.ts,agent_session.ts,agent_activity.ts. - Usage entries (camelCase):
LLMModelUsage(provider,model,inputTokens,inputCachedTokens,outputTokens, ...),TTSModelUsageandSTTModelUsage(audioDurationMs):agents/src/metrics/model_usage.ts. - Metrics (milliseconds):
LLMMetrics.ttftMs(-1when none),TTSMetrics.ttfbMs,STTMetrics.durationMs/audioDurationMs,EOUMetrics.endOfUtteranceDelayMs,metadata.modelName/modelProvider:agents/src/metrics/base.ts. Per-turnChatMessage.metrics.e2eLatency(seconds) andChatMessage.interrupted:agents/src/llm/chat_context.ts.
Verified live on 2026-10-18 against real, self-hosted LiveKit software (no LiveKit Cloud): livekit-server 1.13.10 (livekit/livekit-server image), @livekit/agents 1.9.3 with @livekit/rtc-node 1.1.0 (native binary loaded on linux/arm64, node:22-bookworm-slim), Node 22, OpenTelemetry JS 2.12 / 0.223, lk CLI 2.18.4, sending to the Logthread dev gateway. A real worker (cli.runApp(new ServerOptions({ agentName }))) took an explicit dispatch (lk dispatch create); the entrypoint called instrument(ctx), ctx.connect(), started a real voice.AgentSession with an offline scripted llm.LLM (no STT/TTS/VAD, audio in/out off), ran three generateReply turns and ended when the room was deleted. Seen through the Logthread APIs:
- traces: 30 LiveKit spans in 3 traces (
agent_sessionwithagent_turn,llm_node,llm_request,llm_request_run,session_close, ...;room_connect;session_end_upload), every span stamped withlivekit.room.sid, nolk.pii.*or GenAI content attributes; - metrics:
livekit.agent.llm.tokens(inputcached=false90,cached=true30, output 15 for 3 x 40/10/5 tokens),livekit.agent.llm.ttft_ms,livekit.agent.sessions(started 1, ended 1); - logs: every record with
correlation_id= the room sid (RM_...), includingagent session startedand thesession report(3 turns, usage totals, close reasonuser_initiated).
Not exercised live: STT/TTS/EOU latency and audio-seconds counters (no audio providers), interruptions, agent session error, a shared application or LiveKit Cloud tracer provider.
Found live and fixed: spans did not nest. @livekit/agents starts most spans from context.active(), and only installs a context manager when it builds its own provider, so with the provider passed to setTracerProvider each llm_node/llm_request span was a separate root trace (14 traces instead of 3). When Logthread owns the provider it now registers an AsyncLocalStorageContextManager (@opentelemetry/context-async-hooks) unless one is already registered.
The unit test suite uses fakes with these documented shapes; it does not run a LiveKit server or install @livekit/agents.
Limits#
- The agent framework's own pino logs are not captured. Log through
lt.logger, or use@logthread/node(/autoconsole capture,/pinostream) insidelt.run(...). - Spans are exported by a batch processor every 2 s; spans that end after the shutdown callback has flushed (the last moments of a closing session) can be missed.
- Reading the application's provider tries
telemetry.tracer.getProvider()(if exported) and then the OpenTelemetry global provider. - Interruptions are counted from assistant messages marked
interrupted;agent_false_interruptionevents are not counted. - When Logthread creates the tracer provider it also registers a global OpenTelemetry context manager (
AsyncLocalStorageContextManager) if none is registered, asNodeTracerProvider.register()would; an application's own context manager is left in place. - The metrics SDK holds one
MeterProviderper job; high-cardinality labels are never added (no room or job ids on metrics).