33
44from __future__ import annotations
55
6+ import contextvars
7+ import functools
68import logging
79import os
810import threading
911import time
1012from collections .abc import Iterator
1113from contextlib import contextmanager
1214from datetime import datetime , timedelta , timezone
13- from typing import Any
15+ from typing import TYPE_CHECKING , Any
1416
1517from gooddata_eval .core .agentic ._trace_linker import link_cancel_event , linking_is_inline , warn_from_worker
1618from gooddata_eval .core .config import ReasoningEffort , env_flag , normalize_reasoning_effort
2325 build_experiment_root_span ,
2426 trace_tags ,
2527)
28+ from gooddata_eval .core .langfuse .item_scope import (
29+ SDK_EXPERIMENT_ENVIRONMENT ,
30+ ItemTraceScope ,
31+ JoinedRun ,
32+ joined_run ,
33+ joined_turns ,
34+ release_joined ,
35+ trace_ids_for ,
36+ )
2637
2738# Part of this module's public surface: external callers import both names from here.
2839from gooddata_eval .core .langfuse .observations import TraceSummary as _TraceObj # noqa: F401
40+ from gooddata_eval .core .langfuse .observations import summarize_traces
2941from gooddata_eval .core .langfuse .otlp import Span
3042
43+ if TYPE_CHECKING :
44+ from gooddata_eval .core .agentic ._trace_linker import RunIdentity
45+
3146_log = logging .getLogger (__name__ )
3247
3348
@@ -116,16 +131,22 @@ def get_model_version(
116131 if override :
117132 return override
118133 try :
119- from gooddata_sdk import GoodDataSdk # noqa: PLC0415
120-
121- sdk = GoodDataSdk .create (host , token )
122- setting = sdk .catalog_workspace .get_workspace_setting (workspace_id , "activeLlmProvider" )
123- model = (setting .content or {}).get ("defaultModelId" ) or None
124- if model :
125- return model
134+ return _active_llm_model (host , token , workspace_id )
126135 except Exception :
127- pass
128- return ""
136+ return ""
137+
138+
139+ @functools .cache
140+ def _active_llm_model (host : str , token : str , workspace_id : str ) -> str :
141+ """The workspace's default model id, read once per workspace; a failed read raises and is retried.
142+
143+ Every joined item resolves its run context before the first turn, on the item's own clock.
144+ """
145+ from gooddata_sdk import GoodDataSdk # noqa: PLC0415
146+
147+ sdk = GoodDataSdk .create (host , token )
148+ setting = sdk .catalog_workspace .get_workspace_setting (workspace_id , "activeLlmProvider" )
149+ return (setting .content or {}).get ("defaultModelId" ) or ""
129150
130151
131152def _matches_session (trace : Any , session_id : str ) -> bool :
@@ -201,6 +222,22 @@ def _wait_between_attempts(delay: float) -> bool:
201222 return not cancel .is_set ()
202223
203224
225+ def _fetch_joined_trace (langfuse : HttpxLangfuseClient , session_id : str , * _window : Any ) -> list [Any ]:
226+ """The trace a joined conversation's gen-ai turns were put in, read by its id.
227+
228+ Empty until every joined turn's ``conversation.send_message`` row is ingested: a turn row
229+ lands only when its turn ends, so an early read would cover the first turns alone.
230+ ``joined_turns`` counts one per sent turn, but a turn the chat client retried after
231+ gen-ai opened it leaves a row per attempt, so the count can be met while a later turn
232+ is still in flight; the read then understates cost and latency.
233+ """
234+ trace_id = trace_ids_for (session_id )[0 ]
235+ rows = langfuse .list_observations_for_trace (trace_id )
236+ if sum (r .get ("name" ) == "conversation.send_message" for r in rows ) < joined_turns (session_id ):
237+ return []
238+ return [t for t in summarize_traces (rows ) if t .id == trace_id ]
239+
240+
204241def find_traces_per_conversation (
205242 langfuse : Any ,
206243 conversation_ids : list [str ],
@@ -246,12 +283,14 @@ def find_traces_per_conversation(
246283 break
247284 delay = _INITIAL_DELAY
248285 found : list [Any ] = []
286+ joined = joined_run (cid ) is not None and isinstance (langfuse , HttpxLangfuseClient )
287+ fetch = _fetch_joined_trace if joined else _fetch_traces_for_session
249288 for _attempt in range (_MAX_ATTEMPTS ):
250289 # Attempt first, sleep only between attempts: a trace that is already ingested
251290 # when we look must cost nothing, which is the common case once linking is
252291 # batched to the end of the run.
253292 try :
254- found = _fetch_traces_for_session (langfuse , cid , window_start , window_end , pad )
293+ found = fetch (langfuse , cid , window_start , window_end , pad )
255294 except Exception as exc :
256295 _log .debug ("Langfuse trace fetch failed for %s: %s" , cid , exc )
257296 if found or time .monotonic () + delay > stop_at :
@@ -299,8 +338,13 @@ def _experiment_root_span(
299338 conversation_id : str | None ,
300339 item_input : Any ,
301340 output : Any ,
341+ joined_ids : tuple [str , str ] | None = None ,
302342) -> Span :
303- """gd-eval's own root span for one (dataset item, run) -- the whole experiment item."""
343+ """gd-eval's own root span for one (dataset item, run) -- the whole experiment item.
344+
345+ ``joined_ids`` is the ``(trace_id, span_id)`` gen-ai's turns were put under; the span then
346+ takes those ids and the environment the langfuse SDK forced on those turns.
347+ """
304348 start , end = _span_window (trace , window )
305349 session_id = conversation_id or getattr (trace , "session_id" , None )
306350 custom_tags = run_metadata .get ("tags" ) or ()
@@ -332,7 +376,9 @@ def _experiment_root_span(
332376 "conversation_id" : session_id ,
333377 },
334378 trace_metadata = {"run_name" : run_name },
335- environment = os .environ .get ("LANGFUSE_TRACING_ENVIRONMENT" ),
379+ environment = SDK_EXPERIMENT_ENVIRONMENT if joined_ids else os .environ .get ("LANGFUSE_TRACING_ENVIRONMENT" ),
380+ trace_id = joined_ids [0 ] if joined_ids else None ,
381+ span_id = joined_ids [1 ] if joined_ids else None ,
336382 )
337383
338384
@@ -381,6 +427,22 @@ def observe(
381427 yield None
382428 return
383429
430+ joined = joined_run (conversation_id )
431+ if joined is not None and conversation_id :
432+ yield _export_joined_root (
433+ langfuse ,
434+ joined ,
435+ conversation_id ,
436+ dataset_item_id ,
437+ run_name ,
438+ run_metadata or {},
439+ trace = trace ,
440+ window = window ,
441+ item_input = item_input ,
442+ output = output ,
443+ )
444+ return
445+
384446 try :
385447 dataset_id = langfuse .dataset_id_for_item (dataset_item_id )
386448 except Exception as exc :
@@ -435,6 +497,85 @@ def observe(
435497 yield ScoreTarget (trace_id , span .trace_id , span .span_id )
436498
437499
500+ def _export_joined_root (
501+ langfuse : HttpxLangfuseClient ,
502+ joined : JoinedRun ,
503+ conversation_id : str ,
504+ dataset_item_id : str ,
505+ run_name : str ,
506+ run_metadata : dict [str , Any ],
507+ * ,
508+ trace : Any ,
509+ window : tuple [datetime , datetime ] | None ,
510+ item_input : Any ,
511+ output : Any ,
512+ ) -> ScoreTarget :
513+ """Export the root of a joined conversation's trace and return its one score destination.
514+
515+ The root takes the trace and span ids gen-ai's turns were sent, and the experiment run
516+ they were told they belong to, so Langfuse assembles one experiment item from both.
517+ ``trace`` is that trace as read before scoring; its window spans every gen-ai row.
518+ """
519+ trace_id , span_id = trace_ids_for (conversation_id )
520+ if joined .run_name != run_name :
521+ _log .warning (
522+ "Conversation %s joined run %s, not %s; its root follows the join." ,
523+ conversation_id ,
524+ joined .run_name ,
525+ run_name ,
526+ )
527+ span = _experiment_root_span (
528+ trace_id ,
529+ dataset_item_id ,
530+ joined .dataset_id ,
531+ joined .run_name ,
532+ run_metadata ,
533+ trace = trace ,
534+ window = window ,
535+ conversation_id = conversation_id ,
536+ item_input = item_input ,
537+ output = output ,
538+ joined_ids = (trace_id , span_id ),
539+ )
540+ try :
541+ langfuse .export_spans ([span ])
542+ except Exception as exc :
543+ _log .warning ("Failed to export the experiment span for run %s: %s" , joined .run_name , exc )
544+ warn_from_worker (
545+ f"[langfuse] WARNING: failed to export experiment span run={ joined .run_name } item={ dataset_item_id } : { exc } "
546+ )
547+ return ScoreTarget (trace_id )
548+ finally :
549+ release_joined (conversation_id )
550+ return ScoreTarget (experiment_trace_id = trace_id , experiment_span_id = span_id )
551+
552+
553+ # Scores held by an open ``collect_scores`` block on this thread, sent together when it ends.
554+ _PENDING_SCORES : contextvars .ContextVar [list [dict [str , Any ]] | None ] = contextvars .ContextVar (
555+ "gd_eval_pending_scores" , default = None
556+ )
557+
558+
559+ @contextmanager
560+ def collect_scores (langfuse : HttpxLangfuseClient ) -> Iterator [None ]:
561+ """Hold every ``score_safe`` write made in the block and send them as one batch at its end.
562+
563+ The batch is sent even when the drain was cancelled meanwhile, without retrying, so the
564+ scores made before the interrupt are kept.
565+ """
566+ pending : list [dict [str , Any ]] = []
567+ token = _PENDING_SCORES .set (pending )
568+ try :
569+ yield
570+ finally :
571+ _PENDING_SCORES .reset (token )
572+ if pending :
573+ try :
574+ langfuse .create_scores (pending , wait = _wait_between_attempts )
575+ except Exception as exc :
576+ _log .warning ("Failed to log %d scores: %s" , len (pending ), exc )
577+
578+
438579def score_safe (langfuse : Any , trace_id : Any , ** kwargs : Any ) -> None :
439580 """Create one Langfuse score per destination the target names, ignoring errors.
440581
@@ -448,7 +589,11 @@ def score_safe(langfuse: Any, trace_id: Any, **kwargs: Any) -> None:
448589 if _drain_is_cancelled ():
449590 return
450591 targets = trace_id .destinations () if isinstance (trace_id , ScoreTarget ) else [(str (trace_id ), None )]
592+ pending = _PENDING_SCORES .get ()
451593 for target_id , observation_id in targets :
594+ if pending is not None :
595+ pending .append ({"trace_id" : target_id , "observation_id" : observation_id , ** kwargs })
596+ continue
452597 extra = {"observation_id" : observation_id } if observation_id else {}
453598 try :
454599 langfuse .create_score (trace_id = target_id , ** kwargs , ** extra )
@@ -497,6 +642,42 @@ def log_quality_and_value_scores(
497642 )
498643
499644
645+ def run_context_for (identity : RunIdentity ) -> tuple [str , dict [str , Any ]]:
646+ """``build_run_context`` for an item's ``RunIdentity``."""
647+ return build_run_context (
648+ identity .host ,
649+ identity .token ,
650+ identity .workspace_id ,
651+ identity .dataset_name ,
652+ identity .run_timestamp ,
653+ identity .model_version_override ,
654+ identity .run_metadata_extra ,
655+ identity .reasoning_effort ,
656+ )
657+
658+
659+ def resolve_item_scope (
660+ langfuse : Any , identity : RunIdentity , dataset_item_id : str , * , suffix_runs : bool
661+ ) -> ItemTraceScope | None :
662+ """The item's experiment scope, or None when ``observe`` could not export its root.
663+
664+ That is: a client other than ``HttpxLangfuseClient``, linking switched off, or a dataset
665+ item Langfuse does not know. Sending the join keys then would leave gen-ai's turns under
666+ a root that never arrives.
667+ """
668+ if not isinstance (langfuse , HttpxLangfuseClient ) or env_flag (SKIP_ENV_VAR ):
669+ return None
670+ try :
671+ dataset_id = langfuse .dataset_id_for_item (dataset_item_id )
672+ except Exception as exc :
673+ _log .warning ("Failed to resolve dataset item %s; its chat turns will not join: %s" , dataset_item_id , exc )
674+ return None
675+ if dataset_id is None :
676+ return None
677+ base_name , run_metadata = run_context_for (identity )
678+ return ItemTraceScope (base_name , suffix_runs , dataset_id , dataset_item_id , run_metadata )
679+
680+
500681def build_run_context (
501682 host : str ,
502683 token : str ,
0 commit comments