Skip to content

Commit 1571ced

Browse files
Merge pull request #1876 from gooddata/snapshot-master-abc7a060-to-rel/dev
[bot] Merge master/abc7a060 into rel/dev
2 parents 734e24b + abc7a06 commit 1571ced

36 files changed

Lines changed: 2143 additions & 417 deletions

‎packages/gooddata-eval/AGENTS.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ experiment. The newest and most actively developed package in the repo.
2929
| `core/summary/` | HTTP client for the dedicated dashboard-summary endpoint — a single-shot chat backend, not reporting |
3030
| `core/dataset/` | dataset format, loading, and `from_insights.py` — dataset generation from a workspace's real insights |
3131
| `core/evaluators/` | single-shot evaluators and their registry |
32-
| `core/langfuse/` | the whole Langfuse v4 client: `_env` (base URL + credentials), `otlp` (OTLP/JSON encoding), `experiment` (root-span construction, score targets), `observations` (trace reads), `client` (httpx calls), `sink` (single-shot results as experiments) |
32+
| `core/langfuse/` | the whole Langfuse v4 client: `_env` (base URL + credentials), `otlp` (OTLP/JSON encoding), `experiment` (root-span construction, score targets), `item_scope` (an item's experiment scope sent to gen-ai as baggage, and which conversations joined), `observations` (trace reads), `client` (httpx calls), `sink` (single-shot results as experiments) |
3333
| `core/reporting/` | console and JSON output rendering |
3434
| `core/scoring.py`, `core/runner.py` | scoring and orchestration |
3535
| `core/models.py` | `DatasetItem`, `ChatResult`, `ItemReport` and friends |
@@ -157,6 +157,7 @@ Run this before guessing a `--model` string.
157157
| `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY` | `--langfuse`, `--langfuse-dataset` |
158158
| `GOODDATA_EVAL_CHAT_*` | SSE retry, backoff and timeout knobs |
159159
| `GOODDATA_EVAL_TRACE_LABELS` | `key=value,...` labels stamped on every observation of a chat conversation via W3C baggage; `model_version` also sets the observation `version`, so set it only for a one-model run: every `--model` in one process gets the same labels. Keys must match `[A-Za-z0-9_-]+`; others are skipped with a warning. Dashboard-summary runs are not labelled |
160+
| `GOODDATA_EVAL_JOIN_GENAI_TRACE` | on unless set to `0`, `false`, `no`, `off` or empty. An agentic item's chat turns ask gen-ai to open them under gd-eval's experiment root, so each item is one Langfuse trace with its scores written once. A gen-ai that drops caller baggage keeps today's separate traces; see the gotcha below |
160161
| `GD_EVAL_TIMERS` | same as `--timers` |
161162

162163
A gitignored `.env` at the repo root is the normal place for these; load it with
@@ -190,6 +191,13 @@ ingestion has no pass/fail signal and inflates or misattributes per-item latency
190191
(`run_trace_link_inline` is the synchronous alternative). Do not "fix" a slow item by
191192
making trace scoring synchronous again.
192193

194+
**Turn `GOODDATA_EVAL_JOIN_GENAI_TRACE` off against a gen-ai that accepts caller baggage
195+
but cannot join.** The trace and root ids are derived from the conversation id, and a conversation
196+
counts as joined only when gen-ai reports that trace id on `response_started`. A gen-ai that
197+
reads the baggage without that support tags its own traces with experiment ids under a root
198+
gd-eval never exports. The same orphan is left when an item raises before its deferred
199+
scoring runs: its turns sit in environment `sdk-experiment` without a root or scores.
200+
193201
**A generated item's `expected_output` is copied, never invented — keep it that way.**
194202
`core/dataset/from_insights.py` converts each insight with the platform's own
195203
`declarative_visualization_to_aac()` (from `gooddata-code-convertors`, via `gooddata-sdk`),

‎packages/gooddata-eval/src/gooddata_eval/core/agentic/_langfuse.py‎

Lines changed: 194 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33

44
from __future__ import annotations
55

6+
import contextvars
7+
import functools
68
import logging
79
import os
810
import threading
911
import time
1012
from collections.abc import Iterator
1113
from contextlib import contextmanager
1214
from datetime import datetime, timedelta, timezone
13-
from typing import Any
15+
from typing import TYPE_CHECKING, Any
1416

1517
from gooddata_eval.core.agentic._trace_linker import link_cancel_event, linking_is_inline, warn_from_worker
1618
from gooddata_eval.core.config import ReasoningEffort, env_flag, normalize_reasoning_effort
@@ -23,11 +25,24 @@
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.
2839
from gooddata_eval.core.langfuse.observations import TraceSummary as _TraceObj # noqa: F401
40+
from gooddata_eval.core.langfuse.observations import summarize_traces
2941
from 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

131152
def _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+
204241
def 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+
438579
def 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+
500681
def build_run_context(
501682
host: str,
502683
token: str,

0 commit comments

Comments
 (0)