Early access is open. Free plan during early access. Start free
Logthread

Package

Logthread for LiveKit Agents (Node.js)

@logthread/livekit-agentsv0.1.1Download logthread-livekit-agents-0.1.1.tgz23.2 KBSHA-256 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/api

The 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/agents 1.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#

signalwhatwhere
TracesLiveKit's agent spans (lk.* and gen_ai.* attributes), plus livekit.room.sid, livekit.room.name, livekit.job.id, livekit.agent.name, livekit.worker.idOTLP/HTTP to {endpoint}/v1/traces, Authorization: Bearer <key>
Metricsthe instruments below, cumulative, exported every 60 s and at shutdownOTLP/HTTP to {endpoint}/v1/metrics
Logswhat 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):

nametypeattributes
livekit.agent.llm.tokenscounter ({token})provider, model, direction (input/output), cached (true/false)
livekit.agent.tts.audio_secondscounter (s)provider, model
livekit.agent.stt.audio_secondscounter (s)provider, model
livekit.agent.llm.ttft_mshistogram (ms)provider, model
livekit.agent.tts.ttfb_mshistogram (ms)provider, model
livekit.agent.stt.duration_mshistogram (ms)provider, model (non-streaming STT only)
livekit.agent.eou.delay_mshistogram (ms)none
livekit.agent.e2e_latency_mshistogram (ms)none (per assistant turn)
livekit.agent.sessionscounterstate (started/ended)
livekit.agent.interruptionscounternone

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: false is passed to LiveKit's setTracerProvider, and enforced again on the Logthread exporter, which drops every lk.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.*) and gen_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.logger removes lk.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.

optionenvdefaultnotes
endpointLOGTHREAD_ENDPOINTnoneingest base URL; /v1/... is appended. HTTPS required unless the host is localhost, 127.0.0.1, ::1 or *.localhost, or allowInsecure
apiKeyLOGTHREAD_INGEST_KEYnonesent as Authorization: Bearer <key>; never logged
serviceLOGTHREAD_SERVICEagent name, else livekit-agentservice.name
environmentLOGTHREAD_ENVIRONMENTnonedeployment.environment
allowPiifalselet Logthread receive conversation content in spans
includeTranscriptfalseput user/assistant text in the session report record
traces, metrics, logsLOGTHREAD_LIVEKIT_NO_TRACES, ..._NO_METRICS, ..._NO_LOGS set to trueall onswitch a signal off
metricsIntervalMs60000metric export interval
shutdownTimeoutMs5000bound for flush/shutdown, capped at 5000
attributesnoneextra attributes on every lt.logger record
allowInsecureLOGTHREAD_ALLOW_INSECUREfalseallow plain http to a non-local host
debugLOGTHREAD_DEBUGfalseone stderr line per problem
telemetryimportedyour telemetry from @livekit/agents
tracerProvider, registerSpanProcessornoneshare your own provider (below)
LOGTHREAD_DISABLED=truekill 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 }) and telemetry.FanoutSpanProcessor (add(processor)): agents/src/telemetry/traces.ts; docs page /testing/observability/tracing (Node.js tab, "Strip PII from exported traces"). registerSpanProcessor also 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) and RoomEvent values roomSidChanged, connectionStateChanged, reconnected: packages/livekit-rtc/src/room.ts (node-sdks). RoomEvent.Connected exists but the room does not emit it, so it is not used.
  • Session events: AgentSessionEventTypes includes metrics_collected and session_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, ...), TTSModelUsage and STTModelUsage (audioDurationMs): agents/src/metrics/model_usage.ts.
  • Metrics (milliseconds): LLMMetrics.ttftMs (-1 when none), TTSMetrics.ttfbMs, STTMetrics.durationMs / audioDurationMs, EOUMetrics.endOfUtteranceDelayMs, metadata.modelName / modelProvider: agents/src/metrics/base.ts. Per-turn ChatMessage.metrics.e2eLatency (seconds) and ChatMessage.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_session with agent_turn, llm_node, llm_request, llm_request_run, session_close, ...; room_connect; session_end_upload), every span stamped with livekit.room.sid, no lk.pii.* or GenAI content attributes;
  • metrics: livekit.agent.llm.tokens (input cached=false 90, cached=true 30, 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_...), including agent session started and the session report (3 turns, usage totals, close reason user_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 (/auto console capture, /pino stream) inside lt.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_interruption events are not counted.
  • When Logthread creates the tracer provider it also registers a global OpenTelemetry context manager (AsyncLocalStorageContextManager) if none is registered, as NodeTracerProvider.register() would; an application's own context manager is left in place.
  • The metrics SDK holds one MeterProvider per job; high-cardinality labels are never added (no room or job ids on metrics).