Skip to content

Commit 5cccce1

Browse files
committed
Attach call recordings to Langfuse traces
The bot's audio buffer processor feeds a recorder that uploads through the Langfuse media API once the pipeline has shut down: the whole call on the trace root, stereo with the user left and the bot right, and each turn's user and bot audio on that turn's span as its input and output. Media does not travel over OTLP and is linked to a trace by id, so the upload happens after the conversation ends and costs it nothing. The keys and host come from the OTLP exporter's own configuration, which puts the audio in the same Langfuse project as the spans.
1 parent 9baf489 commit 5cccce1

5 files changed

Lines changed: 595 additions & 1 deletion

File tree

open-telemetry/langfuse/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,50 @@ uv run bot.py
5555

5656
Open your browser to [https://cloud.langfuse.com](https://cloud.langfuse.com) to view traces.
5757

58+
## Call Recordings
59+
60+
Each call's audio is attached to its trace, giving two levels of playback in the Langfuse UI:
61+
62+
- **The whole call**, on the trace root — stereo, with the user on the left channel and the bot on the right, so barge-in stays audible.
63+
- **Each turn**, on that turn's `turn` span — the user's speech as the span's `input`, the bot's reply as its `output`.
64+
65+
Langfuse media travels over the REST media API rather than OTLP, and is linked to a trace by id. Everything uploads after the pipeline shuts down, so recording adds nothing to the latency of the call itself.
66+
67+
`langfuse_recording.py` holds the whole integration. Pipecat reports each turn's audio with the turn number attached, so what's left here is the Langfuse upload and you can lift the file into your own bot:
68+
69+
```python
70+
audiobuffer = AudioBufferProcessor(
71+
num_channels=2, enable_turn_audio=True, auto_start_recording=True
72+
)
73+
# ...place audiobuffer in the pipeline, after transport.output()...
74+
75+
recorder = LangfuseRecorder.from_env()
76+
if recorder:
77+
recorder.attach(audiobuffer, worker)
78+
```
79+
80+
Then stop the recording while the pipeline is still alive, and upload once it isn't:
81+
82+
```python
83+
@transport.event_handler("on_client_disconnected")
84+
async def on_client_disconnected(transport, client):
85+
if recorder:
86+
await recorder.stop_and_collect()
87+
await worker.cancel()
88+
89+
await runner.run()
90+
91+
if recorder:
92+
await recorder.upload()
93+
```
94+
95+
Things worth knowing before running this against a real project:
96+
97+
- **Credentials come from the OTLP config by default.** Langfuse authenticates its OTLP endpoint with HTTP Basic over the same key pair the REST API wants, so the recorder reads the keys out of `OTEL_EXPORTER_OTLP_HEADERS` and the host out of `OTEL_EXPORTER_OTLP_ENDPOINT`. Media and spans therefore reach the same project by construction. Setting `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_SECRET_KEY` / `LANGFUSE_HOST` overrides that, and then it's on you to keep them pointed at the same project — mismatched keys give a 401 on upload while traces keep working, since OTLP authenticates separately.
98+
- **Per-turn clips are capped** at `MAX_TURN_CLIPS` (40) to stay under Langfuse's API rate limit — 30/min on Hobby, 100/min on Core. A longer call keeps its whole-call recording and the first 40 turns of clips. Raise the cap when self-hosting.
99+
- **The whole-call recording is capped** at `MAX_RECORDING_BYTES` (100MB, roughly 20 minutes of stereo 24kHz). Past that the upload is truncated and the recorder says so.
100+
- **Turn 1 begins when the pipeline starts**, not when the user first speaks, so a bot greeting is filed under turn 1 alongside whatever the user says next.
101+
58102
## Langfuse-Specific Configuration
59103

60104
In the `bot.py` file, note the HTTP exporter configuration:

open-telemetry/langfuse/bot.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
LLMContextAggregatorPair,
2020
LLMUserAggregatorParams,
2121
)
22+
from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor
2223
from pipecat.runner.types import RunnerArguments
2324
from pipecat.runner.utils import create_transport
2425
from pipecat.services.cartesia.tts import CartesiaTTSService
@@ -34,6 +35,8 @@
3435
from pipecat.utils.tracing.setup import setup_tracing
3536
from pipecat.workers.runner import WorkerRunner
3637

38+
from langfuse_recording import LangfuseRecorder
39+
3740
load_dotenv(override=True)
3841

3942
IS_TRACING_ENABLED = bool(os.getenv("ENABLE_TRACING"))
@@ -117,6 +120,15 @@ async def on_function_calls_started(service, function_calls):
117120

118121
conversation_id = str(uuid.uuid4())
119122

123+
# Stereo keeps the user on the left channel and the bot on the right, so
124+
# barge-in stays audible in the recording. Turn audio adds one clip per
125+
# speaker per turn, which is what the per-turn players in Langfuse play.
126+
audiobuffer = AudioBufferProcessor(
127+
num_channels=2,
128+
enable_turn_audio=True,
129+
auto_start_recording=True,
130+
)
131+
120132
pipeline = Pipeline(
121133
[
122134
transport.input(),
@@ -125,6 +137,9 @@ async def on_function_calls_started(service, function_calls):
125137
llm,
126138
tts,
127139
transport.output(),
140+
# After the output transport, so both the user's audio and the bot's
141+
# pass through it.
142+
audiobuffer,
128143
assistant_aggregator,
129144
]
130145
)
@@ -142,6 +157,10 @@ async def on_function_calls_started(service, function_calls):
142157
additional_span_attributes={"langfuse.session.id": conversation_id},
143158
)
144159

160+
recorder = LangfuseRecorder.from_env()
161+
if recorder:
162+
recorder.attach(audiobuffer, worker)
163+
145164
@transport.event_handler("on_client_connected")
146165
async def on_client_connected(transport, client):
147166
logger.info(f"Client connected")
@@ -151,13 +170,21 @@ async def on_client_connected(transport, client):
151170
@transport.event_handler("on_client_disconnected")
152171
async def on_client_disconnected(transport, client):
153172
logger.info(f"Client disconnected")
173+
# Collect the audio before cancelling: the recording lives in the
174+
# pipeline, which stops existing right after.
175+
if recorder:
176+
await recorder.stop_and_collect()
154177
await worker.cancel()
155178

156179
runner = WorkerRunner(handle_sigint=False)
157180

158181
await runner.add_workers(worker)
159182
await runner.run()
160183

184+
# Media is linked to the trace by id, so the spans no longer have to be open.
185+
if recorder:
186+
await recorder.upload()
187+
161188

162189
async def bot(runner_args: RunnerArguments):
163190
"""Main bot entry point compatible with Pipecat Cloud."""

open-telemetry/langfuse/env.example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,15 @@ OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"
1515
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <base64_encoded_api_keys>"
1616

1717
# Set to any value to enable console output for debugging
18-
# OTEL_CONSOLE_EXPORT=true
18+
# OTEL_CONSOLE_EXPORT=true
19+
20+
# Call recordings. Nothing to set: the recorder reads the same key pair and host
21+
# out of OTEL_EXPORTER_OTLP_HEADERS and OTEL_EXPORTER_OTLP_ENDPOINT above, so the
22+
# audio always lands in the project the traces went to.
23+
#
24+
# Set these to upload media somewhere else, e.g. a self-hosted deployment. Both
25+
# keys are needed, and the host has to be the same project as the OTLP endpoint
26+
# or the audio attaches to a trace that isn't there.
27+
# LANGFUSE_PUBLIC_KEY=pk-lf-...
28+
# LANGFUSE_SECRET_KEY=sk-lf-...
29+
# LANGFUSE_HOST="https://cloud.langfuse.com"

0 commit comments

Comments
 (0)