From 68b054b7c69bcd0bb2807b923c8284b22ccdd802 Mon Sep 17 00:00:00 2001 From: sid sri Date: Wed, 2 Sep 2026 00:49:06 +0530 Subject: [PATCH 1/4] util-genai: support explicit parent context for spans Allow GenAIInvocation and the langchain-used handler factories (workflow, invoke_local_agent, inference, tool) to accept an explicit parent Context. The langchain callback handler resolves the nearest live ancestor invocation's span from parent_run_id and passes it in, so child spans nest correctly under their caller instead of becoming their own root trace when callbacks run outside the ambient context (e.g. async LangGraph task loops). Fixes #513 Signed-off-by: Siddharth Srivastava <128143077+sidsri14@users.noreply.github.com> Assisted-by: Claude Assisted-by: opencode --- .../genai/langchain/callback_handler.py | 28 +++- .../tests/test_callback_handler.py | 124 +++++++++++++++++- .../util/genai/_agent_invocation.py | 3 + .../util/genai/_inference_invocation.py | 3 + .../opentelemetry/util/genai/_invocation.py | 23 +++- .../util/genai/_tool_invocation.py | 3 + .../util/genai/_workflow_invocation.py | 3 + .../src/opentelemetry/util/genai/handler.py | 9 ++ .../tests/test_handler_workflow.py | 26 +++- 9 files changed, 213 insertions(+), 9 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py index 0e2722fa2..78f790c40 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py @@ -17,6 +17,7 @@ LLMResult, ) +from opentelemetry.context import Context from opentelemetry.instrumentation.genai.langchain.invocation_manager import ( _InvocationManager, ) @@ -38,6 +39,7 @@ response_fields_from_generation, to_input_messages, ) +from opentelemetry.trace import set_span_in_context from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import ( AgentInvocation, @@ -86,6 +88,26 @@ def __init__(self, telemetry_handler: TelemetryHandler) -> None: self._telemetry_handler = telemetry_handler self._invocation_manager = _InvocationManager() + def _parent_context(self, parent_run_id: UUID | None) -> Context | None: + """Return a context carrying the nearest emitted ancestor invocation's span. + + LangChain runs queued callbacks in its own task loop where the + ambient OpenTelemetry context does not hold the parent span, and a + child's ``parent_run_id`` may point at an intermediate chain node that + emits no telemetry. Walk up the run hierarchy to the nearest live + invocation and parent to its span so child spans nest under their + caller instead of becoming their own root trace. + """ + current = parent_run_id + visited: set[UUID] = set() + while current is not None and current not in visited: + visited.add(current) + parent = self._invocation_manager.get_invocation(current) + if parent is not None and parent.span.is_recording(): + return set_span_in_context(parent.span) + current = self._invocation_manager.get_parent_run_id(current) + return None + def on_chain_start( self, serialized: dict[str, Any], @@ -108,7 +130,8 @@ def on_chain_start( metadata.get("workflow_name") if metadata else None ) workflow = self._telemetry_handler.workflow( - name=workflow_name_override or workflow_name + name=workflow_name_override or workflow_name, + parent_context=self._parent_context(parent_run_id), ) workflow.conversation_id = conversation_id if capture_content: @@ -136,6 +159,7 @@ def on_chain_start( if suggested_agent_name_lower != agent_invocation_name_lower: agent = self._telemetry_handler.invoke_local_agent( agent_name=suggested_agent_name, + parent_context=self._parent_context(parent_run_id), ) agent.conversation_id = conversation_id if capture_content: @@ -300,6 +324,7 @@ def on_chat_model_start( llm_invocation = self._telemetry_handler.inference( provider, request_model=request_model, + parent_context=self._parent_context(parent_run_id), ) llm_invocation.conversation_id = _conversation_id(metadata) llm_invocation.input_messages = input_messages @@ -589,6 +614,7 @@ def on_tool_start( tool_description=description, tool_type="function", agent_name=agent_name, + parent_context=self._parent_context(parent_run_id), ) tool_invocation.arguments = arguments tool_call_id = kwargs.get("tool_call_id") diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py index f0f9dba23..2afd046eb 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py @@ -35,6 +35,7 @@ LLMResult, ) +from opentelemetry import context from opentelemetry.instrumentation.genai.langchain.callback_handler import ( OpenTelemetryLangChainCallbackHandler, ) @@ -50,6 +51,7 @@ to_input_messages, to_output_messages, ) +from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import ( AgentInvocation, InferenceInvocation, @@ -169,7 +171,9 @@ def test_workflow_name_from_serialized(self): parent_run_id=None, ) - telemetry.workflow.assert_called_once_with(name="MyLangGraph") + telemetry.workflow.assert_called_once_with( + name="MyLangGraph", parent_context=None + ) def test_workflow_name_overridden_by_metadata(self): handler, telemetry, _, _ = _make_handler() @@ -183,7 +187,9 @@ def test_workflow_name_overridden_by_metadata(self): metadata={"workflow_name": "custom_workflow"}, ) - telemetry.workflow.assert_called_once_with(name="custom_workflow") + telemetry.workflow.assert_called_once_with( + name="custom_workflow", parent_context=None + ) def test_workflow_conversation_id_from_metadata(self): handler, _, workflow_inv, _ = _make_handler() @@ -235,6 +241,7 @@ def test_new_agent_span_created(self): telemetry.invoke_local_agent.assert_called_once_with( agent_name="math_agent", + parent_context=None, ) assert agent_inv.agent_name == "math_agent" assert handler._invocation_manager.get_invocation(run_id) is agent_inv @@ -2674,3 +2681,116 @@ def test_on_chat_model_start_captures_input_messages_when_content_enabled(): assert any(isinstance(p, TextPart) for p in parts) blob = next(p for p in parts if isinstance(p, BlobPart)) assert blob.content == _REAL_PNG_BYTES + + +# --------------------------------------------------------------------------- +# Span parenting across callback types +# --------------------------------------------------------------------------- + + +class TestSpanParenting: + def test_chat_span_nested_under_agent_span( + self, tracer_provider, span_exporter + ): + handler = OpenTelemetryLangChainCallbackHandler( + TelemetryHandler(tracer_provider=tracer_provider) + ) + + agent_run_id = _run_id() + chat_run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "agent_1"}, + inputs={}, + run_id=agent_run_id, + parent_run_id=None, + metadata={"agent_name": "agent_1", "ls_provider": "openai"}, + ) + + # Mask the ambient context so the chat span can only be parented via + # the explicit parent_context resolved from parent_run_id. + mask = context.attach(context.Context()) + try: + handler.on_chat_model_start( + serialized={}, + messages=[[HumanMessage(content="hello")]], + run_id=chat_run_id, + parent_run_id=agent_run_id, + metadata={"model_name": "gpt-4o"}, + ) + finally: + context.detach(mask) + + handler.on_llm_end( + response=LLMResult( + generations=[ + [ + ChatGeneration( + message=AIMessage(content="hi"), + generation_info={"finish_reason": "stop"}, + ) + ] + ] + ), + run_id=chat_run_id, + ) + handler.on_chain_end(outputs={}, run_id=agent_run_id) + + spans = span_exporter.get_finished_spans() + chat = next(s for s in spans if s.name == "chat gpt-4o") + agent = next(s for s in spans if s.name == "invoke_agent agent_1") + assert chat.parent is not None + assert chat.parent.span_id == agent.context.span_id + + def test_tool_span_nested_under_workflow_through_unemitted_node( + self, tracer_provider, span_exporter + ): + handler = OpenTelemetryLangChainCallbackHandler( + TelemetryHandler(tracer_provider=tracer_provider) + ) + + workflow_run_id = _run_id() + # An intermediate chain node that emits no telemetry of its own but + # sits between the workflow and the tool in the run hierarchy. + intermediate_run_id = _run_id() + tool_run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=workflow_run_id, + parent_run_id=None, + ) + + # Register an intermediate chain node that emits no telemetry of its + # own but sits between the workflow and the tool in the hierarchy. + handler.on_chain_start( + serialized={"name": "intermediate_node"}, + inputs={}, + run_id=intermediate_run_id, + parent_run_id=workflow_run_id, + ) + + mask = context.attach(context.Context()) + try: + handler.on_tool_start( + serialized=None, + input_str="{}", + run_id=tool_run_id, + parent_run_id=intermediate_run_id, + inputs={"x": 1}, + ) + finally: + context.detach(mask) + + handler.on_tool_end(output={"x": 43}, run_id=tool_run_id) + handler.on_chain_end(outputs={}, run_id=workflow_run_id) + + spans = span_exporter.get_finished_spans() + tool = next(s for s in spans if s.name == "execute_tool unknown") + workflow = next( + s for s in spans if s.name == "invoke_workflow LangGraph" + ) + assert tool.parent is not None + assert tool.context.trace_id == workflow.context.trace_id + assert tool.parent.span_id == workflow.context.span_id diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py index 3003c0f0f..9e70fd8b8 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py @@ -4,6 +4,7 @@ from __future__ import annotations from opentelemetry._logs import Logger +from opentelemetry.context import Context from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, ) @@ -49,6 +50,7 @@ def __init__( server_address: str | None = None, server_port: int | None = None, agent_name: str | None = None, + parent_context: Context | None = None, ) -> None: """Use handler.invoke_local_agent() or handler.invoke_remote_agent() instead of calling this directly.""" _operation_name = GenAI.GenAiOperationNameValues.INVOKE_AGENT.value @@ -62,6 +64,7 @@ def __init__( if agent_name else _operation_name, span_kind=span_kind, + parent_context=parent_context, ) self._provider: str | None = provider self._request_model: str | None = request_model diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py index e5ad62e08..8c72dccc0 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from opentelemetry._logs import Logger, LogRecord +from opentelemetry.context import Context from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, ) @@ -50,6 +51,7 @@ def __init__( server_port: int | None = None, operation_name: str | None = None, error_type_resolver: ErrorTypeResolver | None = None, + parent_context: Context | None = None, ) -> None: operation_name = ( operation_name or GenAI.GenAiOperationNameValues.CHAT.value @@ -66,6 +68,7 @@ def __init__( else operation_name, span_kind=SpanKind.CLIENT, error_type_resolver=error_type_resolver, + parent_context=parent_context, ) self._provider: str = provider self._request_model: str | None = request_model diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py index 519d387a6..5de47a3f5 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -69,12 +69,14 @@ def __init__( attributes: dict[str, AttributeValue] | None = None, metric_attributes: dict[str, AttributeValue] | None = None, error_type_resolver: ErrorTypeResolver | None = None, + parent_context: Context | None = None, ) -> None: self._tracer = tracer self._metrics_recorder = metrics_recorder self._logger = logger self._completion_hook = completion_hook self._error_type_resolver = error_type_resolver + self._parent_context = parent_context self._operation_name: str = operation_name self.attributes: dict[str, AttributeValue] = ( {} if attributes is None else attributes @@ -105,12 +107,23 @@ def _start( Args: attributes: Initial span attributes available for sampling decisions. + + The span is parented to ``self._parent_context`` when one was supplied + at construction; otherwise it parents from the ambient context. """ - self.span = self._tracer.start_span( - name=self._span_name, - kind=self._span_kind, - attributes=attributes, - ) + if self._parent_context is not None: + self.span = self._tracer.start_span( + name=self._span_name, + kind=self._span_kind, + attributes=attributes, + context=self._parent_context, + ) + else: + self.span = self._tracer.start_span( + name=self._span_name, + kind=self._span_kind, + attributes=attributes, + ) self._span_context = set_span_in_context(self.span) self._monotonic_start_s = timeit.default_timer() self._context_token = attach(self._span_context) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py index 49853828e..8ed671ab9 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py @@ -4,6 +4,7 @@ from __future__ import annotations from opentelemetry._logs import Logger +from opentelemetry.context import Context from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, ) @@ -64,6 +65,7 @@ def __init__( tool_type: str | None = None, tool_description: str | None = None, agent_name: str | None = None, + parent_context: Context | None = None, ) -> None: """Use handler.tool(name) instead of calling this directly.""" _operation_name = GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value @@ -75,6 +77,7 @@ def __init__( operation_name=_operation_name, span_name=f"{_operation_name} {name}" if name else _operation_name, span_kind=SpanKind.INTERNAL, + parent_context=parent_context, ) self.should_capture_content_on_span = should_capture_content_on_spans() self._name: str = name diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py index d09cf7c62..4c94e3243 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py @@ -6,6 +6,7 @@ from dataclasses import asdict from opentelemetry._logs import Logger +from opentelemetry.context import Context from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, ) @@ -40,6 +41,7 @@ def __init__( logger: Logger, completion_hook: CompletionHook, name: str | None, + parent_context: Context | None = None, ) -> None: """Use handler.workflow(name) rather than calling this directly.""" _operation_name = "invoke_workflow" @@ -51,6 +53,7 @@ def __init__( operation_name=_operation_name, span_name=f"{_operation_name} {name}" if name else _operation_name, span_kind=SpanKind.INTERNAL, + parent_context=parent_context, ) self._name: str | None = name self.conversation_id: str | None = None diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index d6ec0a254..edc76bd39 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -41,6 +41,7 @@ LoggerProvider, get_logger, ) +from opentelemetry.context import Context from opentelemetry.metrics import MeterProvider, get_meter from opentelemetry.semconv.schemas import Schemas from opentelemetry.trace import ( @@ -315,6 +316,7 @@ def inference( server_port: int | None = None, operation_name: str | None = None, error_type_resolver: ErrorTypeResolver | None = None, + parent_context: Context | None = None, ) -> InferenceInvocation: """Returns an Inference invocation. Starts span when called. @@ -335,6 +337,7 @@ def inference( server_port=server_port, operation_name=operation_name, error_type_resolver=error_type_resolver, + parent_context=parent_context, ) def embedding( @@ -407,6 +410,7 @@ def tool( tool_type: str | None = None, tool_description: str | None = None, agent_name: str | None = None, + parent_context: Context | None = None, ) -> ToolInvocation: """Returns a Tool invocation. Starts span when called. @@ -428,6 +432,7 @@ def tool( tool_type=tool_type, tool_description=tool_description, agent_name=agent_name, + parent_context=parent_context, ) def start_invoke_local_agent( @@ -493,6 +498,7 @@ def invoke_local_agent( *, request_model: str | None = None, agent_name: str | None = None, + parent_context: Context | None = None, ) -> AgentInvocation: """Returns an agent invocation (INTERNAL span kind). Starts span when called. @@ -512,6 +518,7 @@ def invoke_local_agent( span_kind=SpanKind.INTERNAL, request_model=request_model, agent_name=agent_name, + parent_context=parent_context, ) def invoke_remote_agent( @@ -549,6 +556,7 @@ def invoke_remote_agent( def workflow( self, name: str | None = None, + parent_context: Context | None = None, ) -> WorkflowInvocation: """Returns a Workflow invocation. Starts a span when called. @@ -564,6 +572,7 @@ def workflow( self._logger, self._completion_hook, name, + parent_context=parent_context, ) diff --git a/util/opentelemetry-util-genai/tests/test_handler_workflow.py b/util/opentelemetry-util-genai/tests/test_handler_workflow.py index 1469c69e7..a62474d90 100644 --- a/util/opentelemetry-util-genai/tests/test_handler_workflow.py +++ b/util/opentelemetry-util-genai/tests/test_handler_workflow.py @@ -18,7 +18,11 @@ gen_ai_attributes as GenAI, ) from opentelemetry.test.test_base import TestBase -from opentelemetry.trace import INVALID_SPAN, SpanKind +from opentelemetry.trace import ( + INVALID_SPAN, + SpanKind, + set_span_in_context, +) from opentelemetry.trace.status import StatusCode from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import WorkflowInvocation @@ -90,6 +94,26 @@ def test_workflow_without_conversation_id(self) -> None: spans = self._get_finished_spans() self.assertNotIn(GenAI.GEN_AI_CONVERSATION_ID, spans[0].attributes) + def test_start_workflow_span_parents_to_explicit_parent_context( + self, + ) -> None: + parent_span = self.tracer_provider.get_tracer("parent").start_span( + "parent" + ) + invocation = self.handler.workflow( + name="child", + parent_context=set_span_in_context(parent_span), + ) + child_span = invocation.span + self.assertIsNot(child_span, INVALID_SPAN) + invocation.stop() + parent_span.end() + + spans = self._get_finished_spans() + child = next(s for s in spans if s.name == "invoke_workflow child") + parent = next(s for s in spans if s.name == "parent") + self.assertEqual(child.parent.span_id, parent.context.span_id) + def test_start_workflow_span_kind_is_internal(self) -> None: invocation = self.handler.workflow(name="wf") invocation.stop() From 5d0ac4714dc4f45c94447eef8177567476af3c5c Mon Sep 17 00:00:00 2001 From: sid sri Date: Wed, 2 Sep 2026 00:50:06 +0530 Subject: [PATCH 2/4] langchain: add changelog entry Signed-off-by: Siddharth Srivastava <128143077+sidsri14@users.noreply.github.com> Assisted-by: Claude Assisted-by: opencode --- .../.changelog/528.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/528.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/528.fixed b/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/528.fixed new file mode 100644 index 000000000..6412beb4e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/528.fixed @@ -0,0 +1 @@ +Nest LangChain child spans (agent, workflow, chat, tool) under their caller instead of emitting each as its own root trace when callbacks run outside the ambient context (e.g. async LangGraph task loops). From 87eb7efd1a0d0b24d71dbeeb30cf4de691b5acec Mon Sep 17 00:00:00 2001 From: sid sri Date: Thu, 3 Sep 2026 16:48:59 +0530 Subject: [PATCH 3/4] util-genai & langchain: address review feedback on span parenting Use get_span_context().is_valid instead of span.is_recording() when resolving the parent context, so a valid parent whose span is not recorded (e.g. downstream sampling) still parents the child and keeps the trace tree's shape and depth correct. Also thread parent_context through the retrieval invocation path (handler.retrieval and the langchain on_retriever_start callback) for consistency with the other span-emitting operations. Signed-off-by: Siddharth Srivastava <128143077+sidsri14@users.noreply.github.com> Assisted-by: Claude Assisted-by: opencode --- .../genai/langchain/callback_handler.py | 10 ++++++-- .../tests/test_callback_handler.py | 10 ++++---- .../util/genai/_retrieval_invocation.py | 3 +++ .../src/opentelemetry/util/genai/handler.py | 2 ++ .../tests/test_handler_retrieval.py | 24 ++++++++++++++++++- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py index 78f790c40..cc1d5bec6 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py @@ -97,13 +97,17 @@ def _parent_context(self, parent_run_id: UUID | None) -> Context | None: emits no telemetry. Walk up the run hierarchy to the nearest live invocation and parent to its span so child spans nest under their caller instead of becoming their own root trace. + + A parent is usable as long as its span context is valid even when the + span itself is not recorded (e.g. downstream sampling); parenting to it + keeps the trace tree's shape and depth correct. """ current = parent_run_id visited: set[UUID] = set() while current is not None and current not in visited: visited.add(current) parent = self._invocation_manager.get_invocation(current) - if parent is not None and parent.span.is_recording(): + if parent is not None and parent.span.get_span_context().is_valid: return set_span_in_context(parent.span) current = self._invocation_manager.get_parent_run_id(current) return None @@ -673,7 +677,9 @@ def on_retriever_start( provider = meta.get("ls_vector_store_provider") or None request_model = meta.get("ls_embedding_model") or None retrieval = self._telemetry_handler.retrieval( - provider=provider, request_model=request_model + provider=provider, + request_model=request_model, + parent_context=self._parent_context(parent_run_id), ) retrieval.query_text = query self._invocation_manager.add_invocation_state( diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py index 2afd046eb..adb3cb297 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_callback_handler.py @@ -1384,7 +1384,7 @@ def test_provider_passed_from_metadata(self): ) telemetry.retrieval.assert_called_once_with( - provider="Chroma", request_model=None + provider="Chroma", request_model=None, parent_context=None ) def test_provider_none_when_metadata_absent(self): @@ -1398,7 +1398,7 @@ def test_provider_none_when_metadata_absent(self): ) telemetry.retrieval.assert_called_once_with( - provider=None, request_model=None + provider=None, request_model=None, parent_context=None ) def test_request_model_passed_from_ls_embedding_model(self): @@ -1416,7 +1416,9 @@ def test_request_model_passed_from_ls_embedding_model(self): ) telemetry.retrieval.assert_called_once_with( - provider="Chroma", request_model="text-embedding-3-small" + provider="Chroma", + request_model="text-embedding-3-small", + parent_context=None, ) def test_request_model_none_when_ls_embedding_model_absent(self): @@ -1431,7 +1433,7 @@ def test_request_model_none_when_ls_embedding_model_absent(self): ) telemetry.retrieval.assert_called_once_with( - provider="Chroma", request_model=None + provider="Chroma", request_model=None, parent_context=None ) def test_registered_in_invocation_manager(self): diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_retrieval_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_retrieval_invocation.py index 51a09ce35..fb8522d9e 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_retrieval_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_retrieval_invocation.py @@ -7,6 +7,7 @@ from typing import Any from opentelemetry._logs import Logger +from opentelemetry.context import Context from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, ) @@ -54,6 +55,7 @@ def __init__( request_model: str | None = None, server_address: str | None = None, server_port: int | None = None, + parent_context: Context | None = None, ) -> None: """Use handler.retrieval() instead of calling this directly.""" _operation_name = GenAI.GenAiOperationNameValues.RETRIEVAL.value @@ -67,6 +69,7 @@ def __init__( if data_source_id else _operation_name, span_kind=SpanKind.CLIENT, + parent_context=parent_context, ) self._data_source_id: str | None = data_source_id self._provider: str | None = provider diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index edc76bd39..ef29a23a5 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -210,6 +210,7 @@ def retrieval( request_model: str | None = None, server_address: str | None = None, server_port: int | None = None, + parent_context: Context | None = None, ) -> RetrievalInvocation: """Returns a Retrieval invocation. Starts span when called. @@ -229,6 +230,7 @@ def retrieval( request_model=request_model, server_address=server_address, server_port=server_port, + parent_context=parent_context, ) def start_tool( diff --git a/util/opentelemetry-util-genai/tests/test_handler_retrieval.py b/util/opentelemetry-util-genai/tests/test_handler_retrieval.py index 8273c5a92..95560fb15 100644 --- a/util/opentelemetry-util-genai/tests/test_handler_retrieval.py +++ b/util/opentelemetry-util-genai/tests/test_handler_retrieval.py @@ -19,7 +19,11 @@ from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, ) -from opentelemetry.trace import INVALID_SPAN, SpanKind +from opentelemetry.trace import ( + INVALID_SPAN, + SpanKind, + set_span_in_context, +) from opentelemetry.trace.status import StatusCode from opentelemetry.util.genai.environment_variables import ( OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, @@ -54,6 +58,24 @@ def test_retrieval_creates_span(self) -> None: self.assertIsNot(invocation.span, INVALID_SPAN) invocation.stop() + def test_retrieval_span_parents_to_explicit_parent_context(self) -> None: + parent_span = self.tracer_provider.get_tracer("parent").start_span( + "parent" + ) + invocation = self.handler.retrieval( + data_source_id="H7STPQYOND", + parent_context=set_span_in_context(parent_span), + ) + child_span = invocation.span + self.assertIsNot(child_span, INVALID_SPAN) + invocation.stop() + parent_span.end() + + spans = self._get_finished_spans() + child = next(s for s in spans if s.name == "retrieval H7STPQYOND") + parent = next(s for s in spans if s.name == "parent") + self.assertEqual(child.parent.span_id, parent.context.span_id) + def test_retrieval_span_name_with_data_source_id(self) -> None: invocation = self.handler.retrieval(data_source_id="H7STPQYOND") invocation.stop() From dc55b5f73fe8ceae639f170784b0418777519566 Mon Sep 17 00:00:00 2001 From: sid sri Date: Sat, 5 Sep 2026 22:07:29 +0530 Subject: [PATCH 4/4] util-genai: preserve explicit parent context values in span context Build the attached span context on top of the provided parent context instead of the ambient context, so baggage and other non-span values carried in the parent context survive into span and metric recording. Assisted-by: Claude Code by Anthropic --- .../opentelemetry/util/genai/_invocation.py | 5 ++++- .../tests/test_handler_workflow.py | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py index 5de47a3f5..0531a1b61 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -118,13 +118,16 @@ def _start( attributes=attributes, context=self._parent_context, ) + self._span_context = set_span_in_context( + self.span, self._parent_context + ) else: self.span = self._tracer.start_span( name=self._span_name, kind=self._span_kind, attributes=attributes, ) - self._span_context = set_span_in_context(self.span) + self._span_context = set_span_in_context(self.span) self._monotonic_start_s = timeit.default_timer() self._context_token = attach(self._span_context) diff --git a/util/opentelemetry-util-genai/tests/test_handler_workflow.py b/util/opentelemetry-util-genai/tests/test_handler_workflow.py index a62474d90..646631d6b 100644 --- a/util/opentelemetry-util-genai/tests/test_handler_workflow.py +++ b/util/opentelemetry-util-genai/tests/test_handler_workflow.py @@ -8,6 +8,7 @@ import pytest +from opentelemetry.baggage import get_baggage, set_baggage from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( @@ -114,6 +115,27 @@ def test_start_workflow_span_parents_to_explicit_parent_context( parent = next(s for s in spans if s.name == "parent") self.assertEqual(child.parent.span_id, parent.context.span_id) + def test_start_workflow_preserves_baggage_from_explicit_parent_context( + self, + ) -> None: + parent_span = self.tracer_provider.get_tracer("parent").start_span( + "parent" + ) + parent_context = set_baggage( + "tenant_id", "acme", set_span_in_context(parent_span) + ) + invocation = self.handler.workflow( + name="child", + parent_context=parent_context, + ) + invocation.stop() + parent_span.end() + + self.assertEqual( + get_baggage("tenant_id", context=invocation._span_context), + "acme", + ) + def test_start_workflow_span_kind_is_internal(self) -> None: invocation = self.handler.workflow(name="wf") invocation.stop()