@@ -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+
37583870def _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