Skip to content

fix(livekit): Respect LiveKit's PII and project redaction settings - #2724

Merged
Luca Forstner (lforst) merged 2 commits into
mainfrom
delner/livekit-pii
Nov 3, 2026
Merged

Luca Forstner (lforst) merged 2 commits into
mainfrom
delner/livekit-pii

Conversation

@delner

Copy link
Copy Markdown
Contributor

Problem

LiveKit can be configured to withhold PII from its telemetry, but Braintrust's LiveKit traces ignored that setting. The plugin reads LiveKit's span attributes and events before LiveKit's own redaction runs, so traces still carried transcripts, prompts, model responses, tool arguments and results, and exception details.

LiveKit withholds PII when any of these apply:

  • LIVEKIT_TELEMETRY_ALLOW_PII is falsy (0, false, no, off)
  • the job's LiveKit Cloud project enables redaction
  • a span carries lk.redaction.enabled

Solution

When LiveKit's configuration withholds PII, the plugin applies LiveKit's own redaction rule before anything is logged.

When PII is disallowed it omits:

  • lk.*pii* attributes, gen_ai content attributes, prompt variables and message events
  • transcripts, assistant and heard text, and tool definitions
  • exception messages and stack traces (errors show exception details redacted)

When PII is disallowed it retains:

  • span structure, timing, models, usage, tool choice, participant IDs
  • opt-in audio (governed by BRAINTRUST flags).

NOTE: setTracerProvider(..., { allowPii: false }), is not covered because LiveKit doesn't expose it to the plugin.

Separately, this also fixes the LiveKit e2e websocket cassette being saved after the scenario exited.

…ario exits

The scenario exits with process.exit(0) after a successful run, which
skips its finally block, so websocket cassettes were never written when
recording and never checked for full consumption in replay. Close the
cassette first. The cassette also takes the provider's upstream and
authorization, so it can proxy LiveKit Inference as well as OpenAI.
LiveKit omits PII from its own telemetry when LIVEKIT_TELEMETRY_ALLOW_PII is
falsy, when the job's LiveKit Cloud project enables redaction, or when a span
carries lk.redaction.enabled. The plugin reads native attributes before
LiveKit's redaction runs, so it traced transcripts, prompts and tool payloads
regardless. It now applies LiveKit's own rule (its pii key segment, gen_ai
content keys, prompt variables and message events, with exception details
replaced) whenever LiveKit's configuration disallows PII, and skips the text
LiveKit reports through events: transcripts, assistant and heard text, and
tool definitions.

PII values are still read in memory to resolve links between spans, which
hold only span IDs, so the call stays connected. The job's redaction setting
is read through LiveKit's getJobContext, which auto-instrumentation hooks and
wrapLiveKit hands to the plugin. A span's redaction flag applies to its whole
session, so observations without a native span of their own are covered too.
Structure, timing, models, usage, identities and opt-in audio are kept.
@delner David Elner (delner) self-assigned this Nov 3, 2026
@delner David Elner (delner) changed the title fix(livekit): Respect LiveKit PII and project redaction settings fix(livekit): Respect LiveKit's PII and project redaction settings Nov 3, 2026
@lforst
Luca Forstner (lforst) merged commit 70b861b into main Nov 3, 2026
63 checks passed
@lforst
Luca Forstner (lforst) deleted the delner/livekit-pii branch November 3, 2026 13:53
Luca Forstner (lforst) pushed a commit that referenced this pull request Nov 3, 2026
…2725)

Based on #2724

## Why

Every voice integration has to describe its session the same way under
audio schema v3: sources, a shared timeline, participants, speakers,
recorded segments, and each span's place on that timeline.
`AudioSessionRecorder` owns that shape once, so integrations only
translate their framework's events into calls.

- **Provides common implementation for session shape** to reduce
duplication per integration.
- **Provides session-local IDs** (`participant-N`, `speaker-N`) derived
from native values.
- **With audio capture disabled, it can still describes the session**,
so timing and identities can still be relayed on the trace.

In sum, this enables LiveKit's move to schema v3 (next PR), and future
voice integrations.

## Example

How an integration uses it:

```ts
const audio = new AudioSessionRecorder({
  origin: Date.now(),
  sources: {
    "session-input": { label: "Agent input audio" },
    "agent-output": { label: "Agent audio" },
  },
  audio: { sampleRate: 48_000, segmentBytes }, // omit to describe without recording
  async onFile({ id, data, mimeType, metadata }) {
    // Upload `data` as input.audio[id], then log it with its audio.segments entry.
    session.log({ input: { audio: { [id]: await upload(data, mimeType) } }, metadata });
  },
});

session.log({ metadata: audio.session() }); // schema version, sources, timeline

const { id, added } = audio.participant("PA_x7Kq", {
  type: "user",
  attributes: { "contrib.livekit.participant_id": "PA_x7Kq" },
});
if (added) session.log({ metadata: added }); // audio.participants, first time only
span.log({ metadata: { "audio.participant_ids": [id] } });

audio.record("session-input", pcm, { sampleRate: 16_000, channelCount: 1, offsetMs });
span.log({ metadata: { "audio.selections": audioSelections([{ start, end }]) } });

await audio.finish();
```

## Design notes

```
integration ──calls──► AudioSessionRecorder ──returns──► metadata to log
                            │
                            └─ AudioRecorder (when recording) ─► onFile(segment + entry)
```

- The `AudioSessionRecorder` is responsible for managing the session,
resolving references, etc but it is not responsible for instrumentation
or trace data itself... it merely returns metadata that instrumentation
can log.
- It tracks commonly referenced objects like `participant` and
`speaker`, and helps spans map to a common, PII-less ID. (e.g.
`participant-1`)
Luca Forstner (lforst) pushed a commit that referenced this pull request Nov 3, 2026
Based on #2725

## Why

LiveKit voice traces now follow audio schema v3. Some of the major
changes from this:

- Speech and user turns carry `audio.selections` in reference to the
session's timeline, so a span points at its exact place in the
recording. This is recorded even when audio capture is off.
- Attribution for sources, participants (e.g. a caller), and speakers
(individual voices) including support for diarization (identifying which
text belonged to what speaker.)
- Links between when operations, when possible. e.g. A model response
links to the transcripts in its context, a tool to the response that
requested it, and played speech to the synthesis and model response that
generated it.

## Example

A two-caller order call with a diarizing STT (from the e2e fixture):

```
session          audio.schema.version: 3.0.0 · sources · speakers · segments · timeline
├── user_turn    input: "Hi. I'm calling with my partner, … Where is order demo one zero four two? …"
│   ├── user_speech ×4                                        audio.selections
│   ├── stt  "Hi. I'm calling with my partner,"               speaker-1
│   ├── stt  "who has a question about our order."            speaker-1
│   ├── stt  "Where is order demo one zero four two?"         speaker-2
│   └── …
└── agent_turn   input: (the transcript above) · turn.reply_to → user_turn
    ├── llm_response   uses_context → each stt
    ├── lookup_order   requested_by → llm_response
    ├── llm_response   uses_context → each stt · consumes_tool_result → lookup_order
    ├── tts            generated_by → llm_response
    └── agent_speech   "Your order … is scheduled for delivery on Friday."
                       audio.selections · generated_by → tts
```

Session metadata, illustrated for a call with a room participant:

```json
{
  "audio.schema.version": "3.0.0",
  "audio.sources": { "session-input": { "label": "Agent input audio" }, "agent-output": { "label": "Agent audio" } },
  "audio.speakers": { "speaker-1": { "type": "user", "attributes": { "contrib.livekit.speaker_id": "0" } } },
  "audio.participants": { "participant-1": { "type": "user", "attributes": { "contrib.livekit.participant_id": "PA_x7Kq", "contrib.livekit.participant_kind": "SIP" } } },
  "audio.segments": { "part-0": { "state": "ready", "start_offset_ms": 0, "duration_ms": 30000, "attachment": { "ref": "/input/audio/part-0" } } }
}
```

## Design notes

- Prefer keys and IDs native to the framework rather than heuristics.
Replies match turns by LiveKit's identities, not timestamps. An STT span
runs from the first result to the final, under the turn it began in.
Heard text comes from the sink's synchronized transcript.
- Lean on more reliable mechanics for timing. Agent playout is timed by
the sink, even after an interruption. User speech is selected only with
a local VAD, since provider speech events arrive late.
- Identities can only be added when LiveKit knows them. Without rooms,
diarization, or PII permitted, some forms of identification are not
always possible.
- New fields follow #2724's PII gate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants