Skip to content

Commit ed73ef4

Browse files
authored
Merge pull request #1794 from gooddata/jt/langfuse-v4-otlp-foundations
feat(gooddata-eval): Langfuse v4 foundations — OTLP encoder, experiment span builder, LANGFUSE_BASE_URL
2 parents ebca7d9 + f5703ce commit ed73ef4

10 files changed

Lines changed: 862 additions & 6 deletions

File tree

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from gooddata_eval.core.agentic._trace_linker import link_cancel_event, linking_is_inline, warn_from_worker
2020
from gooddata_eval.core.config import ReasoningEffort, env_flag, normalize_reasoning_effort
21+
from gooddata_eval.core.langfuse._env import resolve_base_url
2122

2223
_log = logging.getLogger(__name__)
2324

@@ -113,7 +114,7 @@ class HttpxLangfuseClient:
113114
"""Minimal Langfuse client using httpx — works on Python 3.14 (no Langfuse SDK needed)."""
114115

115116
def __init__(self) -> None:
116-
host = os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com").rstrip("/")
117+
host = resolve_base_url()
117118
pub = os.environ.get("LANGFUSE_PUBLIC_KEY", "")
118119
sec = os.environ.get("LANGFUSE_SECRET_KEY", "")
119120
if not pub or not sec:

packages/gooddata-eval/src/gooddata_eval/core/dataset/langfuse_source.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
Credentials are read from the standard Langfuse environment variables:
99
LANGFUSE_PUBLIC_KEY — your public key (pk-lf-...)
1010
LANGFUSE_SECRET_KEY — your secret key (sk-lf-...)
11-
LANGFUSE_HOST — base URL, e.g. https://us.cloud.langfuse.com (default)
11+
LANGFUSE_BASE_URL — base URL, e.g. https://us.cloud.langfuse.com (preferred)
12+
LANGFUSE_HOST — base URL, legacy alias for LANGFUSE_BASE_URL
1213
"""
1314

1415
import base64
@@ -17,17 +18,17 @@
1718

1819
import httpx
1920

21+
from gooddata_eval.core.langfuse._env import resolve_base_url
2022
from gooddata_eval.core.models import DatasetItem, SummaryInput
2123

22-
_DEFAULT_HOST = "https://cloud.langfuse.com"
2324
_PAGE_SIZE = 100
2425

2526
_T = TypeVar("_T")
2627

2728

2829
def _make_client() -> httpx.Client:
2930
"""Build an httpx client with Langfuse basic-auth headers."""
30-
host = os.environ.get("LANGFUSE_HOST", _DEFAULT_HOST).rstrip("/")
31+
host = resolve_base_url()
3132
pub = os.environ.get("LANGFUSE_PUBLIC_KEY", "")
3233
sec = os.environ.get("LANGFUSE_SECRET_KEY", "")
3334
if not pub or not sec:
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# (C) 2026 GoodData Corporation
2+
"""Langfuse environment resolution: base URL and credentials, shared by all Langfuse call sites."""
3+
4+
from __future__ import annotations
5+
6+
import base64
7+
import os
8+
9+
import httpx
10+
11+
_DEFAULT_BASE_URL = "https://us.cloud.langfuse.com"
12+
13+
14+
def resolve_base_url() -> str:
15+
"""Resolve the Langfuse base URL: `LANGFUSE_BASE_URL` > `LANGFUSE_HOST` > the US cloud region GoodData uses."""
16+
base = os.environ.get("LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_HOST") or _DEFAULT_BASE_URL
17+
return base.rstrip("/")
18+
19+
20+
def credentials_present() -> bool:
21+
return bool(os.environ.get("LANGFUSE_PUBLIC_KEY")) and bool(os.environ.get("LANGFUSE_SECRET_KEY"))
22+
23+
24+
def basic_auth_header() -> str:
25+
if not credentials_present():
26+
raise RuntimeError("Langfuse credentials not set. Export LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY.")
27+
pub = os.environ["LANGFUSE_PUBLIC_KEY"]
28+
sec = os.environ["LANGFUSE_SECRET_KEY"]
29+
creds = base64.b64encode(f"{pub}:{sec}".encode()).decode()
30+
return f"Basic {creds}"
31+
32+
33+
def make_http_client(*, timeout: float, transport: httpx.BaseTransport | None = None) -> httpx.Client:
34+
return httpx.Client(
35+
base_url=resolve_base_url(),
36+
headers={"Authorization": basic_auth_header()},
37+
timeout=timeout,
38+
transport=transport,
39+
)
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# (C) 2026 GoodData Corporation
2+
"""Langfuse experiment root-span construction and score-target resolution."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import uuid
8+
from dataclasses import dataclass
9+
from datetime import datetime
10+
from typing import Any
11+
12+
from gooddata_eval.core.langfuse.otlp import (
13+
ATTR_ENVIRONMENT,
14+
ATTR_EXPERIMENT_DATASET_ID,
15+
ATTR_EXPERIMENT_DESCRIPTION,
16+
ATTR_EXPERIMENT_ID,
17+
ATTR_EXPERIMENT_ITEM_EXPECTED_OUTPUT,
18+
ATTR_EXPERIMENT_ITEM_ID,
19+
ATTR_EXPERIMENT_ITEM_METADATA_PREFIX,
20+
ATTR_EXPERIMENT_ITEM_ROOT_OBSERVATION_ID,
21+
ATTR_EXPERIMENT_METADATA_PREFIX,
22+
ATTR_EXPERIMENT_NAME,
23+
ATTR_OBSERVATION_INPUT,
24+
ATTR_OBSERVATION_METADATA_PREFIX,
25+
ATTR_OBSERVATION_OUTPUT,
26+
ATTR_OBSERVATION_TYPE,
27+
ATTR_SESSION_ID,
28+
ATTR_TRACE_METADATA_PREFIX,
29+
ATTR_TRACE_NAME,
30+
ATTR_TRACE_TAGS,
31+
ATTR_VERSION,
32+
Span,
33+
flatten_metadata,
34+
new_span_id,
35+
new_trace_id,
36+
otlp_attribute,
37+
)
38+
39+
# Fixed namespace for deriving experiment ids from run names — arbitrary but stable across processes.
40+
_EXPERIMENT_NAMESPACE = uuid.UUID("6f6e2f5a-2f0e-4f0c-9c1b-8f6f2a3b9d10")
41+
42+
43+
def experiment_id_for(run_name: str) -> str:
44+
return str(uuid.uuid5(_EXPERIMENT_NAMESPACE, run_name))
45+
46+
47+
@dataclass(frozen=True)
48+
class ExperimentRun:
49+
name: str
50+
dataset_id: str
51+
metadata: dict[str, Any] | None = None
52+
description: str | None = None
53+
54+
55+
@dataclass(frozen=True)
56+
class ExperimentItem:
57+
item_id: str
58+
input: Any = None
59+
output: Any = None
60+
expected_output: Any = None
61+
metadata: dict[str, Any] | None = None
62+
63+
64+
def build_experiment_root_span(
65+
run: ExperimentRun | None,
66+
item: ExperimentItem,
67+
*,
68+
start: datetime,
69+
end: datetime,
70+
trace_name: str,
71+
session_id: str | None = None,
72+
version: str | None = None,
73+
tags: tuple[str, ...] = (),
74+
observation_metadata: dict[str, Any] | None = None,
75+
trace_metadata: dict[str, Any] | None = None,
76+
environment: str | None = None,
77+
) -> Span:
78+
"""Build the single root span gd-eval emits per (dataset item, run).
79+
80+
With `run=None` this is a plain observation span carrying no `langfuse.experiment.*`
81+
attributes at all — used when there is no experiment to attach the item to.
82+
"""
83+
if end < start:
84+
end = start
85+
span_id = new_span_id()
86+
87+
attributes: list[dict[str, Any]] = [otlp_attribute(ATTR_OBSERVATION_TYPE, "span")]
88+
if item.input is not None:
89+
attributes.append(otlp_attribute(ATTR_OBSERVATION_INPUT, json.dumps(item.input, default=str)))
90+
if item.output is not None:
91+
attributes.append(otlp_attribute(ATTR_OBSERVATION_OUTPUT, json.dumps(item.output, default=str)))
92+
attributes.extend(flatten_metadata(ATTR_OBSERVATION_METADATA_PREFIX, observation_metadata))
93+
94+
attributes.append(otlp_attribute(ATTR_TRACE_NAME, trace_name))
95+
if session_id is not None:
96+
attributes.append(otlp_attribute(ATTR_SESSION_ID, session_id))
97+
if version is not None:
98+
attributes.append(otlp_attribute(ATTR_VERSION, version))
99+
if environment is not None:
100+
attributes.append(otlp_attribute(ATTR_ENVIRONMENT, environment))
101+
if tags:
102+
attributes.append(otlp_attribute(ATTR_TRACE_TAGS, list(tags)))
103+
attributes.extend(flatten_metadata(ATTR_TRACE_METADATA_PREFIX, trace_metadata))
104+
105+
if run is not None:
106+
attributes.append(otlp_attribute(ATTR_EXPERIMENT_ID, experiment_id_for(run.name)))
107+
attributes.append(otlp_attribute(ATTR_EXPERIMENT_NAME, run.name))
108+
attributes.append(otlp_attribute(ATTR_EXPERIMENT_DATASET_ID, run.dataset_id))
109+
if run.description is not None:
110+
attributes.append(otlp_attribute(ATTR_EXPERIMENT_DESCRIPTION, run.description))
111+
attributes.extend(flatten_metadata(ATTR_EXPERIMENT_METADATA_PREFIX, run.metadata))
112+
113+
attributes.append(otlp_attribute(ATTR_EXPERIMENT_ITEM_ID, item.item_id))
114+
attributes.append(otlp_attribute(ATTR_EXPERIMENT_ITEM_ROOT_OBSERVATION_ID, span_id))
115+
if item.expected_output is not None:
116+
attributes.append(
117+
otlp_attribute(ATTR_EXPERIMENT_ITEM_EXPECTED_OUTPUT, json.dumps(item.expected_output, default=str))
118+
)
119+
attributes.extend(flatten_metadata(ATTR_EXPERIMENT_ITEM_METADATA_PREFIX, item.metadata))
120+
121+
return Span(trace_id=new_trace_id(), span_id=span_id, name=trace_name, start=start, end=end, attributes=attributes)
122+
123+
124+
class ScoreTarget(str):
125+
"""Where a score for one evaluated item is written: the gen-ai trace, gd-eval's own
126+
experiment root observation, or both.
127+
128+
The `str` value is the gen-ai trace id when present, else the experiment trace id, else
129+
empty — so a `ScoreTarget` can be used directly wherever a plain trace id string was used.
130+
"""
131+
132+
gen_ai_trace_id: str | None
133+
experiment_trace_id: str | None
134+
experiment_span_id: str | None
135+
136+
def __new__(
137+
cls,
138+
gen_ai_trace_id: str | None = None,
139+
experiment_trace_id: str | None = None,
140+
experiment_span_id: str | None = None,
141+
) -> ScoreTarget:
142+
value = gen_ai_trace_id or experiment_trace_id or ""
143+
instance = super().__new__(cls, value)
144+
instance.gen_ai_trace_id = gen_ai_trace_id
145+
instance.experiment_trace_id = experiment_trace_id
146+
instance.experiment_span_id = experiment_span_id
147+
return instance
148+
149+
def destinations(self) -> list[tuple[str, str | None]]:
150+
"""Score write destinations as `(trace_id, observation_id)` pairs, gen-ai first."""
151+
targets: list[tuple[str, str | None]] = []
152+
if self.gen_ai_trace_id:
153+
targets.append((self.gen_ai_trace_id, None))
154+
if self.experiment_trace_id:
155+
targets.append((self.experiment_trace_id, self.experiment_span_id))
156+
return targets

0 commit comments

Comments
 (0)