Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/per-job-log-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@livekit/agents': minor
---

Add per-job context to the global Pino logger for session-level log filtering

The global logger now automatically includes `jobId` and `roomName` on every log line during an active job. This makes it possible to filter all SDK-internal logs (TTS/STT metrics, speech events, AgentSession lifecycle) by job in log aggregation tools like NewRelic, Datadog, or Grafana.

Context is set when a job starts and cleared after shutdown callbacks complete.
2 changes: 2 additions & 0 deletions agents/src/ipc/job_proc_lazy_main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { pathToFileURL } from 'node:url';
import type { Logger } from 'pino';
import { type Agent, isAgent } from '../generator.js';
import { JobContext, JobProcess, type RunningJobInfo, runWithJobContextAsync } from '../job.js';
import { setJobContext } from '../log.js';
import { initializeLogger, log } from '../log.js';
import { Future, shortuuid } from '../utils.js';
import { defaultInitializeProcessFunc } from '../worker.js';
Expand Down Expand Up @@ -184,6 +185,7 @@ const startJob = (
logger.error({ error }, 'error while shutting down the job'),
);

setJobContext({});
safeSend({ case: 'done', value: undefined });
joinFuture.resolve();
})();
Expand Down
8 changes: 3 additions & 5 deletions agents/src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import type { Logger } from 'pino';
import type { InferenceExecutor } from './ipc/inference_executor.js';
import { log } from './log.js';
import { log, setJobContext } from './log.js';
import { flushOtelLogs, setupCloudTracer, uploadSessionReport } from './telemetry/index.js';
import { isCloud } from './utils.js';
import type { AgentSession } from './voice/agent_session.js';
Expand Down Expand Up @@ -139,10 +139,8 @@ export class JobContext<ProcessUserData = Record<string, unknown>> {
this.#onShutdown = onShutdown;
this.onParticipantConnected = this.onParticipantConnected.bind(this);
this.#room.on(RoomEvent.ParticipantConnected, this.onParticipantConnected);
this.#logger = log().child({
jobId: this.#info.job.id,
roomName: this.#info.job.room?.name,
});
setJobContext({ jobId: this.#info.job.id, roomName: this.#info.job.room?.name });
this.#logger = log();
this.#inferenceExecutor = inferenceExecutor;
this._sessionDirectory = path.join(os.tmpdir(), 'livekit-agents', `job-${this.#info.job.id}`);
}
Expand Down
32 changes: 31 additions & 1 deletion agents/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ export type LoggerOptions = {
// This avoids the "dual package hazard". Symbol.for() returns the same Symbol
// across all module instances, and globalThis is shared process-wide.
const LOGGER_KEY = Symbol.for('@livekit/agents:logger');
const BASE_LOGGER_KEY = Symbol.for('@livekit/agents:baseLogger');
const JOB_CONTEXT_KEY = Symbol.for('@livekit/agents:jobContext');
const LOGGER_OPTIONS_KEY = Symbol.for('@livekit/agents:loggerOptions');
const OTEL_ENABLED_KEY = Symbol.for('@livekit/agents:otelEnabled');

type GlobalState = {
[LOGGER_KEY]?: Logger;
[BASE_LOGGER_KEY]?: Logger;
[JOB_CONTEXT_KEY]?: Record<string, unknown>;
[LOGGER_OPTIONS_KEY]?: LoggerOptions;
[OTEL_ENABLED_KEY]?: boolean;
};
Expand All @@ -40,6 +44,29 @@ export const log = () => {
return logger;
};

/**
* Sets per-job context fields on the global logger. All subsequent calls to
* {@link log} will return a child logger that includes these fields on every
* log line (e.g. `jobId`, `roomName`).
*
* Call with an empty object to clear the context (e.g. after a job ends).
*
* @remarks
* LiveKit workers process one job at a time, so mutating the global logger
* is safe — there is no risk of concurrent jobs interleaving context.
*
* @internal
*/
export const setJobContext = (ctx: Record<string, unknown>) => {
if (!globals[BASE_LOGGER_KEY]) {
globals[BASE_LOGGER_KEY] = globals[LOGGER_KEY];
}
const hasFields = Object.keys(ctx).length > 0;
globals[JOB_CONTEXT_KEY] = hasFields ? ctx : undefined;
const base = globals[BASE_LOGGER_KEY]!;
globals[LOGGER_KEY] = hasFields ? base.child(ctx) : base;
};

/** @internal */
export const initializeLogger = ({ pretty, level }: LoggerOptions) => {
globals[LOGGER_OPTIONS_KEY] = { pretty, level };
Expand Down Expand Up @@ -90,8 +117,11 @@ export const enableOtelLogging = () => {
{ stream: new OtelDestination(), level: 'debug' },
];

globals[LOGGER_KEY] = pino(
const newBase = pino(
{ level: logLevel, serializers: { error: pino.stdSerializers.err } },
multistream(streams),
);
globals[BASE_LOGGER_KEY] = newBase;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
const activeJobCtx = globals[JOB_CONTEXT_KEY];
globals[LOGGER_KEY] = activeJobCtx ? newBase.child(activeJobCtx) : newBase;
};
Loading