Skip to content

Commit 22c6596

Browse files
Wire tier-2b Langfuse Generation fixtures
Move 023 (generation rendering + payload truncation) and 024 (prompt linkage) from _UNIT_TESTED_FIXTURES into _SUPPORTED_FIXTURES, driven through a LangfuseObserver + InMemoryLangfuseClient recorder. Completes the Langfuse tier of the fixture-harness catch-up; test-only, no library change, no pin bump. Adds a generation runner that asserts the Generation observation (model / modelParameters / usage / input-output payload + prompt-entity link) nested under the node span, plus content_repeat synthesis + payload_byte_cap truncation (023) and a prompt-backend Langfuse reference carried via PromptResult.observability_entities (024). The value-matcher gained nested-dict recursion for metadata.prompt. No deferrals.
1 parent 9310fad commit 22c6596

1 file changed

Lines changed: 136 additions & 8 deletions

File tree

tests/conformance/test_observability.py

Lines changed: 136 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,11 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
182182
"035-caller-invocation-id-uuid",
183183
"036-caller-invocation-id-non-uuid",
184184
"059-implementation-attribution-langfuse",
185+
# Tier 2b: Langfuse Generation observation (proposal 0031 §8.4.3/§8.4.4)
186+
# -- model / modelParameters / usage / input-output payload (with
187+
# truncation) and prompt-entity linkage.
188+
"023-langfuse-generation-rendering",
189+
"024-langfuse-prompt-linkage",
185190
# proposal 0052 attribution fixture (case 1) + proposal 0061
186191
# (case 2: the §5.1 attribution lands on the detached trace's own
187192
# openarmature.invocation span). Wired together now that 0061
@@ -298,14 +303,10 @@ def _reset_otel_global_tracer_provider(restore_to: object) -> None:
298303
_UNIT_TESTED_FIXTURES: dict[str, str] = {
299304
fixture_id: reason
300305
for fixture_ids, reason in (
301-
# Fixture-harness catch-up tier 2a wired the trace-shape Langfuse
302-
# fixtures (022/031/032), the invocation-id fixtures (035/036), and the
303-
# attribution fixture (059). 023/024 (Langfuse Generation) are tier 2b;
304-
# 033 (detached multi-trace) is tier 4.
305-
(
306-
("023-langfuse-generation-rendering", "024-langfuse-prompt-linkage"),
307-
"proposal 0031 Langfuse generation/prompt-linkage; covered by test_observability_langfuse.py",
308-
),
306+
# Fixture-harness catch-up tier 2 wired the trace-shape Langfuse
307+
# fixtures (022/031/032), invocation-id (035/036), attribution (059) in
308+
# 2a, and the Langfuse Generation fixtures (023/024) in 2b. 033 (detached
309+
# multi-trace) is tier 4.
309310
(
310311
("033-langfuse-detached-trace-mode",),
311312
"proposal 0035/0061 Langfuse detached-trace mode; covered by test_observability_langfuse.py",
@@ -556,6 +557,11 @@ async def test_observability_fixture(fixture_path: Path) -> None:
556557
"036-caller-invocation-id-non-uuid",
557558
}:
558559
await _run_invocation_id_fixture(spec)
560+
elif fixture_id in {
561+
"023-langfuse-generation-rendering",
562+
"024-langfuse-prompt-linkage",
563+
}:
564+
await _run_langfuse_generation_fixture(spec)
559565
elif fixture_id in {
560566
"012-otel-llm-payload-default-off",
561567
"013-otel-llm-payload-enabled",
@@ -2649,6 +2655,19 @@ def _langfuse_value_matches(
26492655
and set(cast("Mapping[str, Any]", expected)).issubset(_LANGFUSE_MATCHER_SUBKEYS)
26502656
):
26512657
return _langfuse_matcher_subkeys_match(actual, cast("Mapping[str, Any]", expected), params)
2658+
# A regular NON-empty nested mapping (e.g. 024 metadata.prompt): recurse per
2659+
# key so inner tokens (rendered_hash: <any-string>) still apply. Subset over
2660+
# keys -- every expected key must be present and match; actual MAY carry
2661+
# extras. An empty expected dict falls through to exact equality below
2662+
# (rather than vacuously matching any mapping).
2663+
if isinstance(expected, Mapping) and expected:
2664+
if not isinstance(actual, Mapping):
2665+
return False
2666+
actual_map = cast("Mapping[str, Any]", actual)
2667+
return all(
2668+
k in actual_map and _langfuse_value_matches(actual_map[k], v, bindings=bindings, params=params)
2669+
for k, v in cast("Mapping[str, Any]", expected).items()
2670+
)
26522671
return bool(actual == expected)
26532672

26542673

@@ -2821,6 +2840,50 @@ async def _run_invocation_id_case(case: Mapping[str, Any]) -> None:
28212840
assert actual == val, f"trace.metadata.{key} {actual!r} != {val!r}"
28222841

28232842

2843+
async def _run_langfuse_generation_fixture(spec: Mapping[str, Any]) -> None:
2844+
"""Driver for the Langfuse Generation fixtures (023 generation rendering +
2845+
truncation, 024 prompt linkage). Builds a calls_llm graph, records into an
2846+
InMemoryLangfuseClient under the fixture's observer config, and asserts the
2847+
Generation observation nested under the node span.
2848+
"""
2849+
for case in cast("list[dict[str, Any]]", spec["cases"]):
2850+
case_name = cast("str", case["name"])
2851+
try:
2852+
await _run_langfuse_generation_case(case)
2853+
except AssertionError as e:
2854+
raise AssertionError(f"case {case_name!r}: {e}") from e
2855+
2856+
2857+
async def _run_langfuse_generation_case(case: Mapping[str, Any]) -> None:
2858+
import openarmature
2859+
from openarmature.observability.langfuse import InMemoryLangfuseClient, LangfuseObserver
2860+
2861+
graph, state_cls, provider = _build_simple_llm_graph(case, populate_caller_metadata=False)
2862+
client = InMemoryLangfuseClient()
2863+
cfg = cast("dict[str, Any]", case.get("langfuse_observer") or {})
2864+
lf_kwargs: dict[str, Any] = {"client": client}
2865+
if "disable_provider_payload" in cfg:
2866+
lf_kwargs["disable_provider_payload"] = bool(cfg["disable_provider_payload"])
2867+
if "payload_byte_cap" in cfg:
2868+
lf_kwargs["payload_byte_cap"] = int(cfg["payload_byte_cap"])
2869+
observer = LangfuseObserver(**lf_kwargs)
2870+
graph.attach_observer(observer)
2871+
state = _make_state_instance(case, state_cls)
2872+
try:
2873+
await graph.invoke(state)
2874+
await graph.drain()
2875+
finally:
2876+
observer.shutdown()
2877+
await provider.aclose()
2878+
2879+
assert len(client.traces) == 1, f"expected 1 Langfuse trace; got {len(client.traces)}"
2880+
trace = next(iter(client.traces.values()))
2881+
bindings: dict[str, Any] = {}
2882+
params = {"implementation_name": openarmature.__implementation_name__}
2883+
expected = cast("dict[str, Any]", case["expected"]["langfuse_trace"])
2884+
_assert_langfuse_trace_shape(trace, expected, bindings=bindings, params=params)
2885+
2886+
28242887
# ---------------------------------------------------------------------------
28252888
# Fixture 010 — log correlation
28262889
#
@@ -3755,6 +3818,55 @@ async def _update_body(_s: Any, _payload: dict[str, Any] = update_block) -> dict
37553818
return builder.compile(), state_cls, providers
37563819

37573820

3821+
def _assert_langfuse_generation_fields(
3822+
exp_name: str | None,
3823+
match: Any,
3824+
exp: Mapping[str, Any],
3825+
*,
3826+
bindings: dict[str, Any],
3827+
params: Mapping[str, Any],
3828+
) -> None:
3829+
"""Generation-observation fields beyond the base span shape (023/024):
3830+
model / modelParameters / usage, the input parse-or-truncation shapes, and
3831+
the prompt-entity link. Each is asserted only when present, so it is inert
3832+
for span / tool observations. The placeholder-capable fields go through the
3833+
value-matcher (consistent with metadata); usage is a typed integer record.
3834+
"""
3835+
if "model" in exp:
3836+
assert _langfuse_value_matches(match.model, exp["model"], bindings=bindings, params=params), (
3837+
f"{exp_name!r}: model {match.model!r} did not match {exp['model']!r}"
3838+
)
3839+
if "modelParameters" in exp:
3840+
assert _langfuse_value_matches(
3841+
match.model_parameters, exp["modelParameters"], bindings=bindings, params=params
3842+
), f"{exp_name!r}: modelParameters {match.model_parameters!r} != {exp['modelParameters']!r}"
3843+
if "usage" in exp:
3844+
u = cast("dict[str, Any]", exp["usage"])
3845+
got = None if match.usage is None else (match.usage.input, match.usage.output, match.usage.total)
3846+
assert got == (u["input"], u["output"], u["total"]), f"{exp_name!r}: usage {got!r} != {u!r}"
3847+
if "prompt_entity_link" in exp:
3848+
assert _langfuse_value_matches(
3849+
match.prompt_entity_link, exp["prompt_entity_link"], bindings=bindings, params=params
3850+
), (
3851+
f"{exp_name!r}: prompt_entity_link {match.prompt_entity_link!r} "
3852+
f"did not match {exp['prompt_entity_link']!r}"
3853+
)
3854+
if exp.get("prompt_entity_link_absent") is True:
3855+
assert match.prompt_entity_link is None, (
3856+
f"{exp_name!r}: expected no prompt_entity_link; got {match.prompt_entity_link!r}"
3857+
)
3858+
if "input_parses_as_messages" in exp:
3859+
# Under-cap input is the native message list (§8.7); compare directly.
3860+
assert match.input == exp["input_parses_as_messages"], (
3861+
f"{exp_name!r}: input {match.input!r} did not parse as {exp['input_parses_as_messages']!r}"
3862+
)
3863+
if exp.get("input_is_raw_string_with_marker") is True:
3864+
# Over-cap input falls through to the raw truncated string + §5.5.5 marker.
3865+
assert isinstance(match.input, str) and "[truncated" in match.input, (
3866+
f"{exp_name!r}: expected a raw truncated string with marker; got {match.input!r}"
3867+
)
3868+
3869+
37583870
def _assert_langfuse_observation_tree(
37593871
trace: Any,
37603872
expected: list[dict[str, Any]],
@@ -3802,6 +3914,7 @@ def _assert_langfuse_observation_tree(
38023914
assert match.metadata.get(key) == val, (
38033915
f"{exp_name!r}: metadata.{key} {match.metadata.get(key)!r} != {val!r}"
38043916
)
3917+
_assert_langfuse_generation_fields(exp_name, match, exp, bindings=bindings or {}, params=params or {})
38053918
children = cast("list[dict[str, Any]] | None", exp.get("children"))
38063919
if children:
38073920
_assert_langfuse_observation_tree(
@@ -4146,6 +4259,13 @@ def _materialize_typed_messages(messages_spec: Sequence[Mapping[str, Any]]) -> l
41464259
for m in messages_spec:
41474260
role = m.get("role")
41484261
content = m.get("content")
4262+
# content_repeat synthesis (023 case 2 / fixture 014, mirroring the OTel
4263+
# _materialize_messages helper): N repetitions of a single char to drive
4264+
# payload truncation. The fixtures use a single-byte ASCII char, so the
4265+
# char count equals the byte count.
4266+
cr = cast("Mapping[str, Any] | None", m.get("content_repeat"))
4267+
if cr is not None:
4268+
content = cast("str", cr["char"]) * int(cr["bytes"])
41494269
if role == "system":
41504270
out.append(SystemMessage(content=_require_text_content(role, content)))
41514271
elif role == "user":
@@ -4179,6 +4299,13 @@ def _render_prompt_result(case: Mapping[str, Any], prompt_name: str) -> Any:
41794299
rendered = rendered.replace("{{" + key + "}}", str(value)).replace("{{ " + key + " }}", str(value))
41804300
messages: list[Message] = [UserMessage(content=rendered)]
41814301
now = datetime.now(UTC)
4302+
# A backend that exposes a Langfuse Prompt reference (024 case 1,
4303+
# mock_with_langfuse_reference) surfaces it as the langfuse_prompt
4304+
# observability entity; the observer reads it to link the Generation.
4305+
observability_entities: dict[str, Any] | None = None
4306+
reference = entry.get("langfuse_prompt_reference")
4307+
if reference is not None:
4308+
observability_entities = {"langfuse_prompt": reference}
41824309
return PromptResult(
41834310
name=cast("str", entry["name"]),
41844311
version=cast("str", entry["version"]),
@@ -4189,6 +4316,7 @@ def _render_prompt_result(case: Mapping[str, Any], prompt_name: str) -> Any:
41894316
variables=variables,
41904317
fetched_at=now,
41914318
rendered_at=now,
4319+
observability_entities=observability_entities,
41924320
)
41934321

41944322

0 commit comments

Comments
 (0)