dvalle08 commited on
Commit
3f6837a
·
1 Parent(s): 5e3ba57

feat: Add Langfuse tracing support and UI enhancements

Browse files

- Updated .env.example and README.md to include LANGFUSE_PROJECT_ID and LANGFUSE_PUBLIC_TRACES configurations.
- Enhanced session bootstrap payload to include Langfuse settings for tracing.
- Implemented UI components for displaying trace history and dropdown functionality.
- Updated main.js to manage trace dropdown interactions and render trace history.
- Added tests to validate Langfuse tracing behavior and ensure proper metadata handling.

.env.example CHANGED
@@ -36,8 +36,10 @@ OLLAMA_API_KEY=ollama
36
  LANGFUSE_ENABLED=false
37
  LANGFUSE_HOST=https://cloud.langfuse.com
38
  LANGFUSE_BASE_URL= # Optional alternative to LANGFUSE_HOST
 
39
  LANGFUSE_PUBLIC_KEY=
40
  LANGFUSE_SECRET_KEY=
 
41
  LANGFUSE_TRACE_FINALIZE_TIMEOUT_MS=8000
42
  LANGFUSE_POST_TOOL_RESPONSE_TIMEOUT_MS=30000
43
  LANGFUSE_MAX_PENDING_TRACE_TASKS=200
 
36
  LANGFUSE_ENABLED=false
37
  LANGFUSE_HOST=https://cloud.langfuse.com
38
  LANGFUSE_BASE_URL= # Optional alternative to LANGFUSE_HOST
39
+ LANGFUSE_PROJECT_ID= # Required for frontend deep links: project/<project_id>/...
40
  LANGFUSE_PUBLIC_KEY=
41
  LANGFUSE_SECRET_KEY=
42
+ LANGFUSE_PUBLIC_TRACES=false # Mark traces public so non-members can open shared links
43
  LANGFUSE_TRACE_FINALIZE_TIMEOUT_MS=8000
44
  LANGFUSE_POST_TOOL_RESPONSE_TIMEOUT_MS=30000
45
  LANGFUSE_MAX_PENDING_TRACE_TASKS=200
README.md CHANGED
@@ -167,12 +167,22 @@ LLM_PROVIDER=ollama
167
  LANGFUSE_ENABLED=true
168
  LANGFUSE_HOST=https://cloud.langfuse.com
169
  # LANGFUSE_BASE_URL=https://us.cloud.langfuse.com # optional alternative
 
170
  LANGFUSE_PUBLIC_KEY=pk-lf-...
171
  LANGFUSE_SECRET_KEY=sk-lf-...
 
172
  ```
173
 
174
  Each finalized user transcript creates a new trace with spans `stt`, `llm`, and `tts`.
175
  The Streamlit client generates a new `session_id` on each Connect click and sends it to the agent.
 
 
 
 
 
 
 
 
176
 
177
  ### LLM runtime resilience
178
 
 
167
  LANGFUSE_ENABLED=true
168
  LANGFUSE_HOST=https://cloud.langfuse.com
169
  # LANGFUSE_BASE_URL=https://us.cloud.langfuse.com # optional alternative
170
+ LANGFUSE_PROJECT_ID=clkpwwm0m000gmm094odg11gi
171
  LANGFUSE_PUBLIC_KEY=pk-lf-...
172
  LANGFUSE_SECRET_KEY=sk-lf-...
173
+ LANGFUSE_PUBLIC_TRACES=false
174
  ```
175
 
176
  Each finalized user transcript creates a new trace with spans `stt`, `llm`, and `tts`.
177
  The Streamlit client generates a new `session_id` on each Connect click and sends it to the agent.
178
+ The header includes a live traces dropdown that updates as finalized turns arrive.
179
+ Each entry shows the full `trace_id`, local created-at time, and an `Open Trace` link to:
180
+ `https://cloud.langfuse.com/project/<project_id>/traces/<trace_id>`.
181
+
182
+ Notes:
183
+
184
+ - `LANGFUSE_PROJECT_ID` is required to build trace deep links in the UI.
185
+ - Session pages are intentionally not linked from the frontend because they are project-member scoped in Langfuse Cloud.
186
 
187
  ### LLM runtime resilience
188
 
src/agent/traces/turn_tracer.py CHANGED
@@ -1997,6 +1997,7 @@ def _set_root_attributes(
1997
  "langfuse.trace.name": "turn",
1998
  "langfuse.trace.input": turn.user_transcript,
1999
  "langfuse.trace.output": trace_output,
 
2000
  "langfuse.trace.metadata.room_id": turn.room_id,
2001
  "langfuse.trace.metadata.participant_id": turn.participant_id,
2002
  "langfuse.trace.metadata.turn_id": turn.turn_id,
 
1997
  "langfuse.trace.name": "turn",
1998
  "langfuse.trace.input": turn.user_transcript,
1999
  "langfuse.trace.output": trace_output,
2000
+ "langfuse.trace.public": bool(settings.langfuse.LANGFUSE_PUBLIC_TRACES),
2001
  "langfuse.trace.metadata.room_id": turn.room_id,
2002
  "langfuse.trace.metadata.participant_id": turn.participant_id,
2003
  "langfuse.trace.metadata.turn_id": turn.turn_id,
src/api/session_bootstrap.py CHANGED
@@ -36,6 +36,10 @@ class SessionBootstrapPayload:
36
  session_id: str
37
  dispatch_id: str | None
38
  dispatch_worker_id: str | None
 
 
 
 
39
 
40
 
41
  def build_session_bootstrap_payload() -> SessionBootstrapPayload:
@@ -85,6 +89,9 @@ def _build_session_bootstrap_payload_once() -> SessionBootstrapPayload:
85
  assigned_worker_id = state.worker_id
86
  break
87
 
 
 
 
88
  payload = SessionBootstrapPayload(
89
  room_name=room_name,
90
  token=token_data.token,
@@ -92,6 +99,14 @@ def _build_session_bootstrap_payload_once() -> SessionBootstrapPayload:
92
  session_id=session_id,
93
  dispatch_id=getattr(dispatch, "id", None),
94
  dispatch_worker_id=assigned_worker_id,
 
 
 
 
 
 
 
 
95
  )
96
  logger.info(
97
  "Prepared session bootstrap payload room=%s participant=%s dispatch_id=%s worker_id=%s",
@@ -103,6 +118,22 @@ def _build_session_bootstrap_payload_once() -> SessionBootstrapPayload:
103
  return payload
104
 
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def _is_retryable_bootstrap_error(exc: Exception) -> bool:
107
  if isinstance(exc, livekit_api.TwirpError):
108
  return exc.code in RETRYABLE_TWIRP_CODES
 
36
  session_id: str
37
  dispatch_id: str | None
38
  dispatch_worker_id: str | None
39
+ langfuse_enabled: bool
40
+ langfuse_host: str | None
41
+ langfuse_project_id: str | None
42
+ langfuse_public_traces: bool
43
 
44
 
45
  def build_session_bootstrap_payload() -> SessionBootstrapPayload:
 
89
  assigned_worker_id = state.worker_id
90
  break
91
 
92
+ langfuse_host = _normalize_langfuse_host()
93
+ langfuse_project_id = _normalize_langfuse_project_id()
94
+
95
  payload = SessionBootstrapPayload(
96
  room_name=room_name,
97
  token=token_data.token,
 
99
  session_id=session_id,
100
  dispatch_id=getattr(dispatch, "id", None),
101
  dispatch_worker_id=assigned_worker_id,
102
+ langfuse_enabled=bool(
103
+ settings.langfuse.LANGFUSE_ENABLED
104
+ and langfuse_host
105
+ and langfuse_project_id
106
+ ),
107
+ langfuse_host=langfuse_host,
108
+ langfuse_project_id=langfuse_project_id,
109
+ langfuse_public_traces=bool(settings.langfuse.LANGFUSE_PUBLIC_TRACES),
110
  )
111
  logger.info(
112
  "Prepared session bootstrap payload room=%s participant=%s dispatch_id=%s worker_id=%s",
 
118
  return payload
119
 
120
 
121
+ def _normalize_langfuse_host() -> str | None:
122
+ host = settings.langfuse.LANGFUSE_HOST or settings.langfuse.LANGFUSE_BASE_URL
123
+ if not isinstance(host, str):
124
+ return None
125
+ normalized = host.strip().rstrip("/")
126
+ return normalized or None
127
+
128
+
129
+ def _normalize_langfuse_project_id() -> str | None:
130
+ project_id = settings.langfuse.LANGFUSE_PROJECT_ID
131
+ if not isinstance(project_id, str):
132
+ return None
133
+ normalized = project_id.strip()
134
+ return normalized or None
135
+
136
+
137
  def _is_retryable_bootstrap_error(exc: Exception) -> bool:
138
  if isinstance(exc, livekit_api.TwirpError):
139
  return exc.code in RETRYABLE_TWIRP_CODES
src/core/settings.py CHANGED
@@ -196,7 +196,7 @@ class LLMSettings(CoreSettings):
196
  description="OpenAI-compatible Ollama endpoint",
197
  )
198
  OLLAMA_MODEL: str = Field(
199
- default= "ministral-3:8b", #"qwen3:8b" #"qwen3.5:4b", #"ministral-3:8b", #"qwen2.5:7b"
200
  description="Ollama model tag",
201
  )
202
  OLLAMA_API_KEY: Optional[str] = Field(
@@ -262,6 +262,14 @@ class LangfuseSettings(CoreSettings):
262
  default=None,
263
  description="Alternative to LANGFUSE_HOST",
264
  )
 
 
 
 
 
 
 
 
265
  LANGFUSE_TRACE_FINALIZE_TIMEOUT_MS: float = Field(
266
  default=8000.0,
267
  ge=0.0,
 
196
  description="OpenAI-compatible Ollama endpoint",
197
  )
198
  OLLAMA_MODEL: str = Field(
199
+ default= "kimi-k2.5", #"ministral-3:8b", #"qwen2.5:7b" #"qwen3:8b" #"qwen3.5:4b",
200
  description="Ollama model tag",
201
  )
202
  OLLAMA_API_KEY: Optional[str] = Field(
 
262
  default=None,
263
  description="Alternative to LANGFUSE_HOST",
264
  )
265
+ LANGFUSE_PROJECT_ID: Optional[str] = Field(
266
+ default="cmlrbwznk04ogad07cosnpxoh",
267
+ description="Langfuse project ID used to build UI deep links",
268
+ )
269
+ LANGFUSE_PUBLIC_TRACES: bool = Field(
270
+ default=True,
271
+ description="Mark emitted Langfuse traces as public for shareable URLs",
272
+ )
273
  LANGFUSE_TRACE_FINALIZE_TIMEOUT_MS: float = Field(
274
  default=8000.0,
275
  ge=0.0,
src/ui/index.html CHANGED
@@ -63,6 +63,11 @@
63
  align-items: center;
64
  gap: 12px;
65
  }
 
 
 
 
 
66
  .logo {
67
  font-size: 18px;
68
  font-weight: 700;
@@ -164,6 +169,119 @@
164
  background: rgba(239, 68, 68, 0.1);
165
  border-color: rgba(239, 68, 68, 0.5);
166
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
 
168
  /* Main layout — single column, top to bottom */
169
  .main {
@@ -663,6 +781,20 @@
663
  }
664
 
665
  @media (max-width: 1100px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  .pipeline-stage-row {
667
  grid-template-columns: repeat(2, minmax(0, 1fr));
668
  }
@@ -692,6 +824,35 @@
692
  }
693
  }
694
  @media (max-width: 700px) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
  .waveform-section {
696
  min-height: 160px;
697
  padding: 14px 14px 10px;
@@ -762,19 +923,40 @@
762
  <span id="status">Idle</span>
763
  </div>
764
  </div>
765
- <div class="controls">
766
- <button id="connect" class="btn-primary">
767
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
768
- Connect
769
- </button>
770
- <button id="disconnect" class="btn-danger" disabled>
771
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
772
- Disconnect
773
- </button>
774
- <button id="mute" disabled>
775
- <svg id="mic-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
776
- Mute
777
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  </div>
779
  </div>
780
 
 
63
  align-items: center;
64
  gap: 12px;
65
  }
66
+ .header-right {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 10px;
70
+ }
71
  .logo {
72
  font-size: 18px;
73
  font-weight: 700;
 
169
  background: rgba(239, 68, 68, 0.1);
170
  border-color: rgba(239, 68, 68, 0.5);
171
  }
172
+ .trace-dropdown {
173
+ position: relative;
174
+ min-width: 360px;
175
+ }
176
+ .trace-dropdown-toggle {
177
+ width: 100%;
178
+ border-radius: 10px;
179
+ border: 1px solid var(--border);
180
+ background: var(--bg-tertiary);
181
+ color: var(--text-primary);
182
+ padding: 8px 10px;
183
+ font-size: 11px;
184
+ font-weight: 600;
185
+ line-height: 1;
186
+ display: inline-flex;
187
+ align-items: center;
188
+ justify-content: space-between;
189
+ gap: 8px;
190
+ cursor: pointer;
191
+ transition: border-color 0.2s, background 0.2s;
192
+ }
193
+ .trace-dropdown-toggle:hover:not(:disabled) {
194
+ border-color: rgba(108, 143, 255, 0.45);
195
+ background: var(--bg-card);
196
+ }
197
+ .trace-dropdown-toggle:disabled {
198
+ opacity: 0.45;
199
+ cursor: not-allowed;
200
+ }
201
+ .trace-dropdown-label {
202
+ overflow: hidden;
203
+ text-overflow: ellipsis;
204
+ white-space: nowrap;
205
+ }
206
+ .trace-dropdown-chevron {
207
+ width: 14px;
208
+ height: 14px;
209
+ color: var(--text-secondary);
210
+ flex-shrink: 0;
211
+ transition: transform 0.2s ease;
212
+ }
213
+ .trace-dropdown-toggle[aria-expanded="true"] .trace-dropdown-chevron {
214
+ transform: rotate(180deg);
215
+ }
216
+ .trace-dropdown-menu {
217
+ position: absolute;
218
+ top: calc(100% + 6px);
219
+ right: 0;
220
+ width: min(520px, 78vw);
221
+ max-height: 320px;
222
+ overflow-y: auto;
223
+ border-radius: 10px;
224
+ border: 1px solid var(--border-light);
225
+ background: #111725;
226
+ box-shadow: 0 10px 32px rgba(0, 0, 0, 0.35);
227
+ z-index: 60;
228
+ padding: 8px;
229
+ }
230
+ .trace-dropdown-empty {
231
+ font-size: 11px;
232
+ color: var(--text-muted);
233
+ padding: 8px;
234
+ }
235
+ .trace-dropdown-list {
236
+ display: flex;
237
+ flex-direction: column;
238
+ gap: 6px;
239
+ }
240
+ .trace-entry {
241
+ display: flex;
242
+ align-items: center;
243
+ justify-content: space-between;
244
+ gap: 8px;
245
+ padding: 8px;
246
+ border-radius: 8px;
247
+ border: 1px solid rgba(108, 143, 255, 0.18);
248
+ background: rgba(108, 143, 255, 0.05);
249
+ }
250
+ .trace-entry-meta {
251
+ min-width: 0;
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 3px;
255
+ }
256
+ .trace-entry-id {
257
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
258
+ font-size: 11px;
259
+ color: var(--text-primary);
260
+ overflow: hidden;
261
+ text-overflow: ellipsis;
262
+ white-space: nowrap;
263
+ }
264
+ .trace-entry-time {
265
+ font-size: 10px;
266
+ color: var(--text-secondary);
267
+ }
268
+ .trace-entry-open {
269
+ flex-shrink: 0;
270
+ padding: 6px 9px;
271
+ border-radius: 7px;
272
+ border: 1px solid rgba(108, 143, 255, 0.5);
273
+ color: #b8c9ff;
274
+ text-decoration: none;
275
+ background: rgba(108, 143, 255, 0.08);
276
+ font-size: 10px;
277
+ font-weight: 700;
278
+ text-transform: uppercase;
279
+ letter-spacing: 0.05em;
280
+ }
281
+ .trace-entry-open:hover {
282
+ border-color: rgba(108, 143, 255, 0.7);
283
+ color: #d7e1ff;
284
+ }
285
 
286
  /* Main layout — single column, top to bottom */
287
  .main {
 
781
  }
782
 
783
  @media (max-width: 1100px) {
784
+ .header {
785
+ flex-wrap: wrap;
786
+ align-items: flex-start;
787
+ gap: 10px;
788
+ }
789
+ .header-right {
790
+ width: 100%;
791
+ justify-content: space-between;
792
+ flex-wrap: wrap;
793
+ }
794
+ .trace-dropdown {
795
+ flex: 1;
796
+ min-width: 280px;
797
+ }
798
  .pipeline-stage-row {
799
  grid-template-columns: repeat(2, minmax(0, 1fr));
800
  }
 
824
  }
825
  }
826
  @media (max-width: 700px) {
827
+ .header {
828
+ padding: 12px 14px;
829
+ }
830
+ .header-left {
831
+ width: 100%;
832
+ justify-content: space-between;
833
+ }
834
+ .header-right {
835
+ width: 100%;
836
+ flex-direction: column;
837
+ align-items: stretch;
838
+ gap: 8px;
839
+ }
840
+ .trace-dropdown {
841
+ width: 100%;
842
+ min-width: 0;
843
+ }
844
+ .trace-dropdown-menu {
845
+ left: 0;
846
+ right: 0;
847
+ width: 100%;
848
+ }
849
+ .controls {
850
+ width: 100%;
851
+ }
852
+ .controls button {
853
+ flex: 1;
854
+ justify-content: center;
855
+ }
856
  .waveform-section {
857
  min-height: 160px;
858
  padding: 14px 14px 10px;
 
923
  <span id="status">Idle</span>
924
  </div>
925
  </div>
926
+ <div class="header-right">
927
+ <div class="trace-dropdown" id="trace-dropdown">
928
+ <button
929
+ type="button"
930
+ class="trace-dropdown-toggle"
931
+ id="trace-dropdown-toggle"
932
+ aria-expanded="false"
933
+ aria-controls="trace-dropdown-menu"
934
+ disabled
935
+ >
936
+ <span class="trace-dropdown-label" id="trace-dropdown-label">Connect to initialize trace links.</span>
937
+ <svg class="trace-dropdown-chevron" viewBox="0 0 16 16" fill="none" aria-hidden="true">
938
+ <path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
939
+ </svg>
940
+ </button>
941
+ <div class="trace-dropdown-menu" id="trace-dropdown-menu" hidden>
942
+ <div class="trace-dropdown-empty" id="trace-dropdown-empty">No traces yet.</div>
943
+ <div class="trace-dropdown-list" id="trace-dropdown-list"></div>
944
+ </div>
945
+ </div>
946
+ <div class="controls">
947
+ <button id="connect" class="btn-primary">
948
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
949
+ Connect
950
+ </button>
951
+ <button id="disconnect" class="btn-danger" disabled>
952
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
953
+ Disconnect
954
+ </button>
955
+ <button id="mute" disabled>
956
+ <svg id="mic-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" x2="12" y1="19" y2="22"/></svg>
957
+ Mute
958
+ </button>
959
+ </div>
960
  </div>
961
  </div>
962
 
src/ui/main.js CHANGED
@@ -59,10 +59,198 @@ const toolDescEl = document.getElementById("live-tool-desc");
59
  const toolListEl = document.getElementById("live-tool-list");
60
  const postToolStageRowEl = document.getElementById("post-tool-stage-row");
61
  const totalTurnCardEl = document.getElementById("total-turn-card");
 
 
 
 
 
 
62
  let activeLiveSpeechId = null;
63
  let liveTurnValues = createEmptyLiveTurnValues();
64
  let toolPhaseExpanded = true;
65
  let toolTurnActive = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  function createEmptyLiveTurnValues() {
68
  return {
@@ -363,6 +551,30 @@ window.addEventListener('DOMContentLoaded', () => {
363
  }
364
  setToolPhaseExpanded(true);
365
  clearToolPipelineView();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  });
367
 
368
  function setStatus(text, state) {
@@ -618,6 +830,7 @@ async function connectToRoom() {
618
 
619
  currentSessionId = bootstrap.session_id || crypto.randomUUID();
620
  currentRoomName = bootstrap.room_name || null;
 
621
 
622
  if (!bootstrap.token) {
623
  throw new Error("Session bootstrap did not return a token");
@@ -664,6 +877,7 @@ async function connectToRoom() {
664
  localTrack = null;
665
  currentSessionId = null;
666
  currentRoomName = null;
 
667
  muted = false;
668
  resetMuteButton();
669
  if (remoteAudioTrack) {
@@ -695,6 +909,8 @@ async function connectToRoom() {
695
  renderTurn(metricsData);
696
  } else if (metricsData.type === "turn_pipeline_summary") {
697
  renderTurnPipelineSummary(metricsData);
 
 
698
  }
699
  } catch (error) {
700
  console.error("Failed to parse metrics:", error);
@@ -734,6 +950,7 @@ async function connectToRoom() {
734
  }
735
  currentSessionId = null;
736
  currentRoomName = null;
 
737
  muted = false;
738
  resetMuteButton();
739
  if (remoteAudioTrack) {
@@ -795,6 +1012,7 @@ async function disconnectRoom() {
795
  if (disconnectSeq === activeConnectionSeq) {
796
  currentSessionId = null;
797
  currentRoomName = null;
 
798
  muted = false;
799
  resetMuteButton();
800
  cleanupWave();
@@ -866,6 +1084,7 @@ setConnectionState(CONNECTION_STATES.IDLE);
866
  setHandoffCardVisible(false);
867
  setTotalCardMode(false);
868
  clearToolPipelineView();
 
869
 
870
  connectBtn.addEventListener("click", () => {
871
  connectToRoom().catch((error) => {
 
59
  const toolListEl = document.getElementById("live-tool-list");
60
  const postToolStageRowEl = document.getElementById("post-tool-stage-row");
61
  const totalTurnCardEl = document.getElementById("total-turn-card");
62
+ const traceDropdownEl = document.getElementById("trace-dropdown");
63
+ const traceDropdownToggleEl = document.getElementById("trace-dropdown-toggle");
64
+ const traceDropdownLabelEl = document.getElementById("trace-dropdown-label");
65
+ const traceDropdownMenuEl = document.getElementById("trace-dropdown-menu");
66
+ const traceDropdownListEl = document.getElementById("trace-dropdown-list");
67
+ const traceDropdownEmptyEl = document.getElementById("trace-dropdown-empty");
68
  let activeLiveSpeechId = null;
69
  let liveTurnValues = createEmptyLiveTurnValues();
70
  let toolPhaseExpanded = true;
71
  let toolTurnActive = false;
72
+ let langfuseEnabled = false;
73
+ let langfuseHost = null;
74
+ let langfuseProjectId = null;
75
+ let traceHistory = [];
76
+ const TRACE_HISTORY_LIMIT = 100;
77
+
78
+ function normalizeNonEmptyString(value) {
79
+ if (typeof value !== "string") return null;
80
+ const trimmed = value.trim();
81
+ return trimmed.length > 0 ? trimmed : null;
82
+ }
83
+
84
+ function normalizeLangfuseHost(value) {
85
+ const normalized = normalizeNonEmptyString(value);
86
+ if (!normalized) return null;
87
+ const withoutSlash = normalized.replace(/\/+$/, "");
88
+ if (!/^https?:\/\//i.test(withoutSlash)) return null;
89
+ return withoutSlash;
90
+ }
91
+
92
+ function buildLangfuseTraceUrl(host, projectId, traceId) {
93
+ return `${host}/project/${encodeURIComponent(projectId)}/traces/${encodeURIComponent(traceId)}`;
94
+ }
95
+
96
+ function formatTraceCreatedAtLocal(epochSeconds) {
97
+ if (typeof epochSeconds !== "number" || !Number.isFinite(epochSeconds)) {
98
+ return "--";
99
+ }
100
+ const date = new Date(epochSeconds * 1000);
101
+ if (Number.isNaN(date.getTime())) {
102
+ return "--";
103
+ }
104
+ return date.toLocaleString(undefined, {
105
+ year: "numeric",
106
+ month: "2-digit",
107
+ day: "2-digit",
108
+ hour: "2-digit",
109
+ minute: "2-digit",
110
+ second: "2-digit",
111
+ });
112
+ }
113
+
114
+ function closeTraceDropdown() {
115
+ if (traceDropdownMenuEl) {
116
+ traceDropdownMenuEl.hidden = true;
117
+ }
118
+ if (traceDropdownToggleEl) {
119
+ traceDropdownToggleEl.setAttribute("aria-expanded", "false");
120
+ }
121
+ }
122
+
123
+ function renderTraceHistory() {
124
+ if (!traceDropdownListEl || !traceDropdownEmptyEl) return;
125
+ traceDropdownListEl.innerHTML = "";
126
+ if (traceHistory.length === 0) {
127
+ traceDropdownEmptyEl.hidden = false;
128
+ return;
129
+ }
130
+
131
+ traceDropdownEmptyEl.hidden = true;
132
+ traceHistory.forEach((item) => {
133
+ const row = document.createElement("div");
134
+ row.className = "trace-entry";
135
+
136
+ const meta = document.createElement("div");
137
+ meta.className = "trace-entry-meta";
138
+
139
+ const idEl = document.createElement("span");
140
+ idEl.className = "trace-entry-id";
141
+ idEl.textContent = item.traceId;
142
+ idEl.title = item.traceId;
143
+
144
+ const timeEl = document.createElement("span");
145
+ timeEl.className = "trace-entry-time";
146
+ timeEl.textContent = `Created at ${formatTraceCreatedAtLocal(item.createdAt)}`;
147
+
148
+ const openLink = document.createElement("a");
149
+ openLink.className = "trace-entry-open";
150
+ openLink.href = item.traceUrl;
151
+ openLink.target = "_blank";
152
+ openLink.rel = "noopener noreferrer";
153
+ openLink.textContent = "Open Trace";
154
+
155
+ meta.appendChild(idEl);
156
+ meta.appendChild(timeEl);
157
+ row.appendChild(meta);
158
+ row.appendChild(openLink);
159
+ traceDropdownListEl.appendChild(row);
160
+ });
161
+ }
162
+
163
+ function updateTraceDropdownUI() {
164
+ const hasLinkConfig = Boolean(langfuseEnabled && langfuseHost && langfuseProjectId);
165
+ let label = "Connect to initialize trace links.";
166
+ let toggleDisabled = true;
167
+
168
+ if (!langfuseEnabled) {
169
+ label = "Langfuse tracing is disabled for this session.";
170
+ } else if (!hasLinkConfig) {
171
+ label = "Langfuse host/project is missing.";
172
+ } else if (traceHistory.length === 0) {
173
+ label = "Waiting for first trace...";
174
+ toggleDisabled = false;
175
+ } else {
176
+ label = `Traces (${traceHistory.length})`;
177
+ toggleDisabled = false;
178
+ }
179
+
180
+ if (traceDropdownLabelEl) {
181
+ traceDropdownLabelEl.textContent = label;
182
+ }
183
+
184
+ if (traceDropdownToggleEl) {
185
+ traceDropdownToggleEl.disabled = toggleDisabled;
186
+ if (toggleDisabled) {
187
+ closeTraceDropdown();
188
+ }
189
+ }
190
+
191
+ if (traceDropdownEmptyEl) {
192
+ if (!langfuseEnabled) {
193
+ traceDropdownEmptyEl.textContent = "Langfuse tracing is disabled.";
194
+ } else if (!hasLinkConfig) {
195
+ traceDropdownEmptyEl.textContent = "Langfuse host/project is missing; links unavailable.";
196
+ } else {
197
+ traceDropdownEmptyEl.textContent = "No traces yet.";
198
+ }
199
+ }
200
+
201
+ renderTraceHistory();
202
+ }
203
+
204
+ function resetTracePanel() {
205
+ langfuseEnabled = false;
206
+ langfuseHost = null;
207
+ langfuseProjectId = null;
208
+ traceHistory = [];
209
+ closeTraceDropdown();
210
+ updateTraceDropdownUI();
211
+ }
212
+
213
+ function configureTracePanelFromBootstrap(bootstrap) {
214
+ langfuseEnabled = bootstrap && bootstrap.langfuse_enabled === true;
215
+ langfuseHost = normalizeLangfuseHost(bootstrap ? bootstrap.langfuse_host : null);
216
+ langfuseProjectId = normalizeNonEmptyString(bootstrap ? bootstrap.langfuse_project_id : null);
217
+ traceHistory = [];
218
+ closeTraceDropdown();
219
+ updateTraceDropdownUI();
220
+ }
221
+
222
+ function handleTraceUpdate(payload) {
223
+ if (!payload || payload.type !== "trace_update") return;
224
+ if (!currentSessionId) return;
225
+
226
+ const payloadSessionId = normalizeNonEmptyString(payload.session_id);
227
+ if (payloadSessionId && payloadSessionId !== currentSessionId) return;
228
+
229
+ const traceId = normalizeNonEmptyString(payload.trace_id);
230
+ if (!traceId) return;
231
+
232
+ if (!(langfuseEnabled && langfuseHost && langfuseProjectId)) {
233
+ updateTraceDropdownUI();
234
+ return;
235
+ }
236
+
237
+ const timestampRaw = (typeof payload.timestamp === "number" && Number.isFinite(payload.timestamp))
238
+ ? payload.timestamp
239
+ : (Date.now() / 1000);
240
+ const timestamp = timestampRaw > 1e12 ? (timestampRaw / 1000) : timestampRaw;
241
+ const traceUrl = buildLangfuseTraceUrl(langfuseHost, langfuseProjectId, traceId);
242
+
243
+ traceHistory = traceHistory.filter((item) => item.traceId !== traceId);
244
+ traceHistory.unshift({
245
+ traceId,
246
+ traceUrl,
247
+ createdAt: timestamp,
248
+ });
249
+ if (traceHistory.length > TRACE_HISTORY_LIMIT) {
250
+ traceHistory = traceHistory.slice(0, TRACE_HISTORY_LIMIT);
251
+ }
252
+ updateTraceDropdownUI();
253
+ }
254
 
255
  function createEmptyLiveTurnValues() {
256
  return {
 
551
  }
552
  setToolPhaseExpanded(true);
553
  clearToolPipelineView();
554
+ if (traceDropdownToggleEl) {
555
+ traceDropdownToggleEl.addEventListener("click", () => {
556
+ if (!traceDropdownMenuEl) return;
557
+ const nextOpen = traceDropdownMenuEl.hidden;
558
+ traceDropdownMenuEl.hidden = !nextOpen;
559
+ traceDropdownToggleEl.setAttribute("aria-expanded", nextOpen ? "true" : "false");
560
+ });
561
+ }
562
+ document.addEventListener("click", (event) => {
563
+ if (!traceDropdownEl) return;
564
+ if (!traceDropdownEl.contains(event.target)) {
565
+ closeTraceDropdown();
566
+ }
567
+ });
568
+ document.addEventListener("keydown", (event) => {
569
+ if (event.key === "Escape") {
570
+ closeTraceDropdown();
571
+ }
572
+ });
573
+ if (traceDropdownListEl) {
574
+ traceDropdownListEl.addEventListener("click", () => {
575
+ closeTraceDropdown();
576
+ });
577
+ }
578
  });
579
 
580
  function setStatus(text, state) {
 
830
 
831
  currentSessionId = bootstrap.session_id || crypto.randomUUID();
832
  currentRoomName = bootstrap.room_name || null;
833
+ configureTracePanelFromBootstrap(bootstrap);
834
 
835
  if (!bootstrap.token) {
836
  throw new Error("Session bootstrap did not return a token");
 
877
  localTrack = null;
878
  currentSessionId = null;
879
  currentRoomName = null;
880
+ resetTracePanel();
881
  muted = false;
882
  resetMuteButton();
883
  if (remoteAudioTrack) {
 
909
  renderTurn(metricsData);
910
  } else if (metricsData.type === "turn_pipeline_summary") {
911
  renderTurnPipelineSummary(metricsData);
912
+ } else if (metricsData.type === "trace_update") {
913
+ handleTraceUpdate(metricsData);
914
  }
915
  } catch (error) {
916
  console.error("Failed to parse metrics:", error);
 
950
  }
951
  currentSessionId = null;
952
  currentRoomName = null;
953
+ resetTracePanel();
954
  muted = false;
955
  resetMuteButton();
956
  if (remoteAudioTrack) {
 
1012
  if (disconnectSeq === activeConnectionSeq) {
1013
  currentSessionId = null;
1014
  currentRoomName = null;
1015
+ resetTracePanel();
1016
  muted = false;
1017
  resetMuteButton();
1018
  cleanupWave();
 
1084
  setHandoffCardVisible(false);
1085
  setTotalCardMode(false);
1086
  clearToolPipelineView();
1087
+ resetTracePanel();
1088
 
1089
  connectBtn.addEventListener("click", () => {
1090
  connectToRoom().catch((error) => {
tests/test_langfuse_turn_tracing.py CHANGED
@@ -277,6 +277,11 @@ def test_turn_trace_has_required_metadata_and_spans(monkeypatch: pytest.MonkeyPa
277
 
278
  fake_tracer = _FakeTracer()
279
  monkeypatch.setattr(metrics_collector_module, "tracer", fake_tracer)
 
 
 
 
 
280
 
281
  room = _FakeRoom()
282
  collector = MetricsCollector(
@@ -340,6 +345,7 @@ def test_turn_trace_has_required_metadata_and_spans(monkeypatch: pytest.MonkeyPa
340
  assert root.attributes["participant_id"] == "web-123"
341
  assert root.attributes["turn_id"]
342
  assert root.attributes["langfuse.trace.output"] == "hi, how can I help?"
 
343
  assert root.attributes["latency_ms.eou_delay"] == pytest.approx(1100.0)
344
  assert root.attributes["latency_ms.stt_finalization"] == pytest.approx(250.0)
345
  assert root.attributes["latency_ms.stt_total"] == pytest.approx(1350.0)
@@ -424,6 +430,50 @@ def test_turn_trace_has_required_metadata_and_spans(monkeypatch: pytest.MonkeyPa
424
  assert trace_updates[0]["trace_id"]
425
 
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  def test_turn_trace_includes_tool_spans_between_llm_and_tts(
428
  monkeypatch: pytest.MonkeyPatch,
429
  ) -> None:
 
277
 
278
  fake_tracer = _FakeTracer()
279
  monkeypatch.setattr(metrics_collector_module, "tracer", fake_tracer)
280
+ monkeypatch.setattr(
281
+ metrics_collector_module.settings.langfuse,
282
+ "LANGFUSE_PUBLIC_TRACES",
283
+ False,
284
+ )
285
 
286
  room = _FakeRoom()
287
  collector = MetricsCollector(
 
345
  assert root.attributes["participant_id"] == "web-123"
346
  assert root.attributes["turn_id"]
347
  assert root.attributes["langfuse.trace.output"] == "hi, how can I help?"
348
+ assert root.attributes["langfuse.trace.public"] is False
349
  assert root.attributes["latency_ms.eou_delay"] == pytest.approx(1100.0)
350
  assert root.attributes["latency_ms.stt_finalization"] == pytest.approx(250.0)
351
  assert root.attributes["latency_ms.stt_total"] == pytest.approx(1350.0)
 
430
  assert trace_updates[0]["trace_id"]
431
 
432
 
433
+ def test_turn_trace_marks_public_when_langfuse_public_traces_enabled(
434
+ monkeypatch: pytest.MonkeyPatch,
435
+ ) -> None:
436
+ import src.agent.traces.metrics_collector as metrics_collector_module
437
+
438
+ fake_tracer = _FakeTracer()
439
+ monkeypatch.setattr(metrics_collector_module, "tracer", fake_tracer)
440
+ monkeypatch.setattr(
441
+ metrics_collector_module.settings.langfuse,
442
+ "LANGFUSE_PUBLIC_TRACES",
443
+ True,
444
+ )
445
+
446
+ room = _FakeRoom()
447
+ collector = MetricsCollector(
448
+ room=room, # type: ignore[arg-type]
449
+ model_name="moonshine",
450
+ room_name=room.name,
451
+ room_id="RM123",
452
+ participant_id="web-123",
453
+ langfuse_enabled=True,
454
+ )
455
+
456
+ async def _run() -> None:
457
+ await collector.on_session_metadata(
458
+ session_id="session-public",
459
+ participant_id="web-123",
460
+ )
461
+ await collector.on_user_input_transcribed("hello", is_final=True)
462
+ await collector.on_metrics_collected(_make_stt_metrics("stt-public"))
463
+ await collector.on_metrics_collected(
464
+ _make_eou_metrics("speech-public", delay=0.8, transcription_delay=0.1)
465
+ )
466
+ await collector.on_metrics_collected(_make_llm_metrics("speech-public"))
467
+ await collector.on_conversation_item_added(role="assistant", content="hi")
468
+ await collector.on_metrics_collected(_make_tts_metrics("speech-public"))
469
+ await collector.wait_for_pending_trace_tasks()
470
+
471
+ asyncio.run(_run())
472
+
473
+ root = fake_tracer.spans[0]
474
+ assert root.attributes["langfuse.trace.public"] is True
475
+
476
+
477
  def test_turn_trace_includes_tool_spans_between_llm_and_tts(
478
  monkeypatch: pytest.MonkeyPatch,
479
  ) -> None:
tests/test_session_bootstrap.py CHANGED
@@ -23,6 +23,11 @@ def test_build_session_bootstrap_payload_returns_fresh_room_and_session(
23
  monkeypatch: pytest.MonkeyPatch,
24
  ) -> None:
25
  captured_calls: list[dict[str, Any]] = []
 
 
 
 
 
26
 
27
  def fake_create_room_token(*, room_name: str) -> LiveKitToken:
28
  return LiveKitToken(
@@ -62,6 +67,14 @@ def test_build_session_bootstrap_payload_returns_fresh_room_and_session(
62
  assert second.token == f"token-{second.room_name}"
63
  assert first.dispatch_worker_id == "worker-123"
64
  assert second.dispatch_worker_id == "worker-123"
 
 
 
 
 
 
 
 
65
 
66
  assert len(captured_calls) == 2
67
  for payload, call in ((first, captured_calls[0]), (second, captured_calls[1])):
@@ -98,6 +111,37 @@ def test_build_session_bootstrap_payload_handles_missing_worker_assignment(
98
  assert payload.dispatch_worker_id is None
99
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  def test_build_session_bootstrap_payload_retries_transient_failure(
102
  monkeypatch: pytest.MonkeyPatch,
103
  ) -> None:
 
23
  monkeypatch: pytest.MonkeyPatch,
24
  ) -> None:
25
  captured_calls: list[dict[str, Any]] = []
26
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_ENABLED", True)
27
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_HOST", "https://cloud.langfuse.com/")
28
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_BASE_URL", None)
29
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_PROJECT_ID", "project-xyz")
30
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_PUBLIC_TRACES", True)
31
 
32
  def fake_create_room_token(*, room_name: str) -> LiveKitToken:
33
  return LiveKitToken(
 
67
  assert second.token == f"token-{second.room_name}"
68
  assert first.dispatch_worker_id == "worker-123"
69
  assert second.dispatch_worker_id == "worker-123"
70
+ assert first.langfuse_enabled is True
71
+ assert second.langfuse_enabled is True
72
+ assert first.langfuse_host == "https://cloud.langfuse.com"
73
+ assert second.langfuse_host == "https://cloud.langfuse.com"
74
+ assert first.langfuse_project_id == "project-xyz"
75
+ assert second.langfuse_project_id == "project-xyz"
76
+ assert first.langfuse_public_traces is True
77
+ assert second.langfuse_public_traces is True
78
 
79
  assert len(captured_calls) == 2
80
  for payload, call in ((first, captured_calls[0]), (second, captured_calls[1])):
 
111
  assert payload.dispatch_worker_id is None
112
 
113
 
114
+ def test_build_session_bootstrap_payload_disables_langfuse_links_when_project_missing(
115
+ monkeypatch: pytest.MonkeyPatch,
116
+ ) -> None:
117
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_ENABLED", True)
118
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_HOST", "https://cloud.langfuse.com")
119
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_BASE_URL", None)
120
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_PROJECT_ID", " ")
121
+ monkeypatch.setattr(settings.langfuse, "LANGFUSE_PUBLIC_TRACES", False)
122
+ monkeypatch.setattr(
123
+ session_bootstrap,
124
+ "create_room_token",
125
+ lambda *, room_name: LiveKitToken(
126
+ token=f"token-{room_name}",
127
+ room_name=room_name,
128
+ identity="web-test",
129
+ ),
130
+ )
131
+ monkeypatch.setattr(
132
+ session_bootstrap,
133
+ "ensure_agent_dispatched_sync",
134
+ lambda **_: _make_dispatch(dispatch_id="dispatch-1", worker_id="worker-1"),
135
+ )
136
+
137
+ payload = session_bootstrap.build_session_bootstrap_payload()
138
+
139
+ assert payload.langfuse_enabled is False
140
+ assert payload.langfuse_host == "https://cloud.langfuse.com"
141
+ assert payload.langfuse_project_id is None
142
+ assert payload.langfuse_public_traces is False
143
+
144
+
145
  def test_build_session_bootstrap_payload_retries_transient_failure(
146
  monkeypatch: pytest.MonkeyPatch,
147
  ) -> None: