From 83a990bb62ed639e8c8727e5735a5886da7cdcd8 Mon Sep 17 00:00:00 2001 From: AryanRG Date: Fri, 23 Oct 2026 01:45:09 +0530 Subject: [PATCH] fix: preserve explicit parent for child events --- langfuse/_client/span.py | 7 ++-- tests/unit/test_otel.py | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/langfuse/_client/span.py b/langfuse/_client/span.py index 917eee994..725f1aac0 100644 --- a/langfuse/_client/span.py +++ b/langfuse/_client/span.py @@ -1125,9 +1125,10 @@ def start_observation( """ if as_type == "event": timestamp = time_ns() - event_span = self._langfuse_client._otel_tracer.start_span( - name=name, start_time=timestamp - ) + with otel_trace_api.use_span(self._otel_span): + event_span = self._langfuse_client._otel_tracer.start_span( + name=name, start_time=timestamp + ) return cast( LangfuseEvent, LangfuseEvent( diff --git a/tests/unit/test_otel.py b/tests/unit/test_otel.py index dfe1336fc..9371e03a3 100644 --- a/tests/unit/test_otel.py +++ b/tests/unit/test_otel.py @@ -1,5 +1,6 @@ import json import logging +from contextlib import ExitStack from datetime import datetime from hashlib import sha256 from typing import List, Sequence @@ -72,6 +73,76 @@ def generate_span_id(self) -> int: return self._span_ids.pop(0) +@pytest.mark.parametrize("active_context", ["none", "parent", "unrelated"]) +@pytest.mark.parametrize("creation_method", ["event", "create_event", "span"]) +def test_child_observation_uses_explicit_parent( + langfuse_memory_client, memory_exporter, active_context, creation_method +): + client = langfuse_memory_client + with ExitStack() as stack: + active = ( + stack.enter_context(client.start_as_current_observation(name="active")) + if active_context != "none" + else None + ) + parent = ( + active + if active_context == "parent" + else client.start_observation(name="manual-parent") + ) + current_id = client.get_current_observation_id() + attributes = { + "name": "child", + "input": {"step": "started"}, + "output": {"step": "finished"}, + "metadata": {"source": "manual"}, + "version": "v1", + "level": "WARNING", + "status_message": "test-status", + } + if creation_method == "create_event": + child = parent.create_event(**attributes) + else: + child = parent.start_observation(as_type=creation_method, **attributes) + if creation_method == "span": + child.end() + + assert client.get_current_observation_id() == current_id + if parent is not active: + parent.end() + + client.flush() + spans = memory_exporter.get_finished_spans() + child_spans = [span for span in spans if span.name == "child"] + assert len(child_spans) == 1 + exported = child_spans[0] + assert exported.parent is not None + assert format(exported.parent.span_id, "016x") == parent.id + assert format(exported.context.trace_id, "032x") == parent.trace_id + assert format(exported.context.span_id, "016x") == child.id + expected_type = "span" if creation_method == "span" else "event" + assert ( + exported.attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE] + == expected_type + ) + assert json.loads( + exported.attributes[LangfuseOtelSpanAttributes.OBSERVATION_INPUT] + ) == {"step": "started"} + assert json.loads( + exported.attributes[LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT] + ) == {"step": "finished"} + assert exported.attributes["langfuse.observation.metadata.source"] == '"manual"' + assert exported.attributes[LangfuseOtelSpanAttributes.VERSION] == "v1" + assert ( + exported.attributes[LangfuseOtelSpanAttributes.OBSERVATION_LEVEL] == "WARNING" + ) + assert exported.attributes[ + LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE + ] == ("test-status") + if creation_method != "span": + assert exported.start_time == exported.end_time + + class TestOTelBase: """Base class for OTEL tests with common fixtures and helper methods."""