From c47cec62c31f07c8ab14260ade0e775c2975e25a Mon Sep 17 00:00:00 2001 From: Ido Gol Date: Wed, 9 Sep 2026 14:49:26 +0300 Subject: [PATCH 1/6] fix(langchain): keep objects out of association properties Metadata values reached span attributes through str(): any non-primitive was stringified, so an object's repr became the attribute value. A model, client or config object renders its constructor state, which routinely includes an API key, and association properties are copied onto every descendant span, so one such value spread across the whole trace. This path is also not gated by TRACELOOP_TRACE_CONTENT, so turning content capture off did not suppress it. Forward plain data only. Primitives are unchanged, lists keep their primitive elements, and a mapping is kept as JSON when every value in it is serializable (json.dumps with no default, so a mapping holding an object raises and the key is dropped). Anything else sanitizes to None and the key is dropped rather than recorded as a stringified object. Documented usage is unaffected: string and numeric labels such as user_id and session_id are primitives and still populate association properties. --- .../langchain/callback_handler.py | 39 +++++-- .../tests/test_metadata_sanitization.py | 100 ++++++++++++++++++ 2 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py diff --git a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py index 15ed817e15..cb52b2f329 100644 --- a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py @@ -106,16 +106,38 @@ def _extract_class_name_from_serialized(serialized: Optional[dict[str, Any]]) -> return "" +_METADATA_PRIMITIVES = (bool, str, bytes, int, float) + + def _sanitize_metadata_value(value: Any) -> Any: - """Convert metadata values to OpenTelemetry-compatible types.""" + """Convert metadata values to OpenTelemetry-compatible types. + + Only plain data is forwarded. An arbitrary object is dropped rather than + stringified: ``str()`` on a model, client or config object renders its + constructor state, which routinely includes credentials the caller never + meant to export. Association properties are also copied onto every + descendant span, so a single such value spreads across the whole trace. + + Returns ``None`` for anything that is not plain data; callers drop those + keys rather than recording a placeholder. + """ if value is None: return None - if isinstance(value, (bool, str, bytes, int, float)): + if isinstance(value, _METADATA_PRIMITIVES): return value if isinstance(value, (list, tuple)): - return [str(_sanitize_metadata_value(v)) for v in value] - # Convert other types to strings - return str(value) + # Keep primitive elements, drop object elements, preserving the + # existing "sequence of strings" attribute shape. + return [str(v) for v in value if isinstance(v, _METADATA_PRIMITIVES)] + if isinstance(value, dict): + # A mapping of plain data is legitimate metadata, so keep it as JSON. + # json.dumps has no ``default``, so a mapping holding a non-serializable + # object raises and the key is dropped instead of being stringified. + try: + return json.dumps(value) + except (TypeError, ValueError): + return None + return None def valid_role(role: str) -> bool: @@ -296,11 +318,14 @@ def _create_span( current_association_properties = ( context_api.get_value("association_properties") or {} ) - # Sanitize metadata values to ensure they're compatible with OpenTelemetry + # Sanitize metadata values to ensure they're compatible with + # OpenTelemetry. Values that are not plain data sanitize to None + # and are dropped. sanitized_metadata = { - k: _sanitize_metadata_value(v) + k: sanitized for k, v in metadata.items() if v is not None + and (sanitized := _sanitize_metadata_value(v)) is not None } try: association_properties_token = context_api.attach( diff --git a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py new file mode 100644 index 0000000000..511186466c --- /dev/null +++ b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py @@ -0,0 +1,100 @@ +"""Metadata values must be plain data, never a stringified object. + +Association properties are set from the caller's ``config={"metadata": ...}`` and +are copied onto every descendant span. Before this test, any non-primitive value +was passed through ``str()``, so an object's repr landed on the whole trace. A +model, client or config object renders its constructor state, which routinely +includes an API key, so one such value exported a credential to the trace +backend. Metadata is also not gated by TRACELOOP_TRACE_CONTENT, so turning +content capture off did not suppress it. + +These are unit tests over the sanitizer: they need no network and no cassette. +""" + +from opentelemetry.instrumentation.langchain.callback_handler import ( + _sanitize_metadata_value, +) + +MARKER = "metadata-object-marker-9f3a" + + +class _ClientLikeObject: + """Stands in for a model/client object whose repr renders its config.""" + + def __init__(self, api_key: str) -> None: + self.api_key = api_key + + def __repr__(self) -> str: + return f"_ClientLikeObject(api_key='{self.api_key}')" + + +def test_primitives_are_preserved(): + """The documented use case - string and numeric labels - keeps working.""" + assert _sanitize_metadata_value("12345") == "12345" + assert _sanitize_metadata_value(42) == 42 + assert _sanitize_metadata_value(1.5) == 1.5 + assert _sanitize_metadata_value(True) is True + assert _sanitize_metadata_value(b"bytes") == b"bytes" + + +def test_falsy_primitives_are_preserved_not_dropped(): + """0, False and "" are values, not absences.""" + assert _sanitize_metadata_value(0) == 0 + assert _sanitize_metadata_value(False) is False + assert _sanitize_metadata_value("") == "" + + +def test_object_is_dropped_not_stringified(): + """The leak: an object's repr must never become the attribute value.""" + assert _sanitize_metadata_value(_ClientLikeObject(MARKER)) is None + + +def test_object_inside_a_list_is_dropped(): + value = _sanitize_metadata_value(["ok", _ClientLikeObject(MARKER)]) + assert value == ["ok"] + assert MARKER not in str(value) + + +def test_plain_dict_is_kept_as_json(): + value = _sanitize_metadata_value({"tenant": "acme", "retries": 2}) + assert value == '{"tenant": "acme", "retries": 2}' + + +def test_dict_holding_an_object_is_dropped(): + """A mapping is only kept when every value in it is plain data.""" + value = _sanitize_metadata_value({"client": _ClientLikeObject(MARKER)}) + assert value is None + + +def test_object_never_reaches_association_properties(instrument_legacy, span_exporter): + """End to end through the callback handler: the object key is absent.""" + from langchain_core.prompts import ChatPromptTemplate + from langchain_core.runnables import RunnableLambda + + chain = ChatPromptTemplate.from_messages( + [("user", "{question}")] + ) | RunnableLambda(lambda prompt: "stubbed") + + chain.invoke( + {"question": "hi"}, + config={ + "metadata": { + "user_id": "12345", + "client": _ClientLikeObject(MARKER), + } + }, + ) + + spans = span_exporter.get_finished_spans() + assert spans, "expected the chain invocation to be traced" + + for span in spans: + for key, value in (span.attributes or {}).items(): + assert MARKER not in str(value), f"marker leaked into {key}" + + # The legitimate label survives on at least one span. + assert any( + "12345" in str(value) + for span in spans + for value in (span.attributes or {}).values() + ), "the primitive metadata label should still be recorded" From c4801a6949f871cf21c3daa7e0c0ad3deb3fb878 Mon Sep 17 00:00:00 2001 From: Ido Gol Date: Wed, 9 Sep 2026 15:06:26 +0300 Subject: [PATCH 2/6] docs(langchain): docstring the functions this branch touches --- .../instrumentation/langchain/callback_handler.py | 2 ++ .../tests/test_metadata_sanitization.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py index cb52b2f329..d6068b8062 100644 --- a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py @@ -187,6 +187,8 @@ def _extract_tool_call_data( class TraceloopCallbackHandler(BaseCallbackHandler): + """LangChain callback handler that records chain, tool and LLM runs as spans.""" + def __init__( self, tracer: Tracer, duration_histogram: Histogram, token_histogram: Histogram ) -> None: diff --git a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py index 511186466c..2493fcc938 100644 --- a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py +++ b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py @@ -22,9 +22,11 @@ class _ClientLikeObject: """Stands in for a model/client object whose repr renders its config.""" def __init__(self, api_key: str) -> None: + """Store the marker the way a real client stores its credential.""" self.api_key = api_key def __repr__(self) -> str: + """Render the credential, as a real client's repr does.""" return f"_ClientLikeObject(api_key='{self.api_key}')" @@ -50,12 +52,14 @@ def test_object_is_dropped_not_stringified(): def test_object_inside_a_list_is_dropped(): + """A sequence keeps its primitive elements and loses its object elements.""" value = _sanitize_metadata_value(["ok", _ClientLikeObject(MARKER)]) assert value == ["ok"] assert MARKER not in str(value) def test_plain_dict_is_kept_as_json(): + """A mapping of plain data stays, encoded as JSON rather than a Python repr.""" value = _sanitize_metadata_value({"tenant": "acme", "retries": 2}) assert value == '{"tenant": "acme", "retries": 2}' From 5a254e4da236059441c964141e2c29b31d09fae6 Mon Sep 17 00:00:00 2001 From: Ido Gol Date: Wed, 16 Sep 2026 18:32:34 +0300 Subject: [PATCH 3/6] test(langchain): scope the metadata leak test to association properties The end-to-end test asserted the marker was absent from every span attribute, which also covered traceloop.entity.input. That path dumps the caller's whole input, metadata included, and is gated by TRACELOOP_TRACE_CONTENT -- documented content capture, not the ungated trace-wide leak this fix addresses. Assert on the association-property attributes instead: user_id still propagates, the object key is gone. --- .../tests/test_metadata_sanitization.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py index 2493fcc938..43220e5f8b 100644 --- a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py +++ b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py @@ -11,6 +11,8 @@ These are unit tests over the sanitizer: they need no network and no cassette. """ +from opentelemetry.semconv_ai import SpanAttributes + from opentelemetry.instrumentation.langchain.callback_handler import ( _sanitize_metadata_value, ) @@ -71,7 +73,14 @@ def test_dict_holding_an_object_is_dropped(): def test_object_never_reaches_association_properties(instrument_legacy, span_exporter): - """End to end through the callback handler: the object key is absent.""" + """End to end through the callback handler: the object key never appears. + + Only association properties are checked. The caller's metadata is also + dumped onto ``traceloop.entity.input``, marker and all, but that path is + gated by TRACELOOP_TRACE_CONTENT and dumps the whole input wholesale -- + content capture working as documented, not the ungated trace-wide leak + this fix is about. + """ from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnableLambda @@ -92,13 +101,18 @@ def test_object_never_reaches_association_properties(instrument_legacy, span_exp spans = span_exporter.get_finished_spans() assert spans, "expected the chain invocation to be traced" - for span in spans: - for key, value in (span.attributes or {}).items(): - assert MARKER not in str(value), f"marker leaked into {key}" - - # The legitimate label survives on at least one span. - assert any( - "12345" in str(value) + prefix = f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}." + properties = { + key: value for span in spans - for value in (span.attributes or {}).values() - ), "the primitive metadata label should still be recorded" + for key, value in (span.attributes or {}).items() + if key.startswith(prefix) + } + + # The legitimate label still propagates to every span... + assert properties.get(f"{prefix}user_id") == "12345" + + # ...while the object is dropped rather than recorded as its repr. + assert f"{prefix}client" not in properties + for key, value in properties.items(): + assert MARKER not in str(value), f"marker leaked into {key}" From 6c0f9fd9a0b57414bf7f2c51f3875f5234ffa1ae Mon Sep 17 00:00:00 2001 From: Ido Gol Date: Thu, 17 Sep 2026 15:35:14 +0300 Subject: [PATCH 4/6] fix(langchain): stringify safe stdlib scalars instead of dropping them The catch-all drop went too wide. UUID, datetime, Decimal, Enum and Path render as their own value, not as constructor state, so they carry none of the credential risk the drop exists for -- and a UUID session_id is the common case, not an edge one. Dropping them reinstated the #2537 symptom that bfb761f set out to fix, now without the "Invalid type" warning that used to make it visible. Route every branch through one predicate: primitives pass through, those stdlib scalars stringify, anything else is dropped. The dict branch keeps its serializable keys and loses only the object-valued ones, the way the list branch already did -- an unrelated key no longer discards the mapping. --- .../langchain/callback_handler.py | 37 +++++++++++--- .../tests/test_metadata_sanitization.py | 50 ++++++++++++++++--- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py index d6068b8062..21ed8d8205 100644 --- a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py @@ -1,6 +1,10 @@ import contextvars +import datetime import json import time +from decimal import Decimal +from enum import Enum +from pathlib import PurePath from typing import Any, Dict, List, Optional, Type, Union from uuid import UUID @@ -108,6 +112,19 @@ def _extract_class_name_from_serialized(serialized: Optional[dict[str, Any]]) -> _METADATA_PRIMITIVES = (bool, str, bytes, int, float) +# Stdlib scalars whose str() is the value itself rather than constructor state, +# so they carry no credential risk. session_id as a UUID is the common case. +_METADATA_SCALARS = (UUID, datetime.date, datetime.time, Decimal, PurePath, Enum) + + +def _metadata_scalar(value: Any) -> Any: + """Return `value` as a span-attribute scalar, or None if it is not one.""" + if isinstance(value, _METADATA_PRIMITIVES): + return value + if isinstance(value, _METADATA_SCALARS): + return str(value) + return None + def _sanitize_metadata_value(value: Any) -> Any: """Convert metadata values to OpenTelemetry-compatible types. @@ -123,19 +140,25 @@ def _sanitize_metadata_value(value: Any) -> Any: """ if value is None: return None - if isinstance(value, _METADATA_PRIMITIVES): - return value + scalar = _metadata_scalar(value) + if scalar is not None: + return scalar if isinstance(value, (list, tuple)): - # Keep primitive elements, drop object elements, preserving the + # Keep the scalar elements, drop the object ones, preserving the # existing "sequence of strings" attribute shape. - return [str(v) for v in value if isinstance(v, _METADATA_PRIMITIVES)] + return [str(v) for v in value if _metadata_scalar(v) is not None] if isinstance(value, dict): # A mapping of plain data is legitimate metadata, so keep it as JSON. - # json.dumps has no ``default``, so a mapping holding a non-serializable - # object raises and the key is dropped instead of being stringified. + # Drop only the object-valued keys, the way the list branch does. + kept = { + key: scalar + for key, item in value.items() + if (scalar := _metadata_scalar(item)) is not None + } try: - return json.dumps(value) + return json.dumps(kept) if kept else None except (TypeError, ValueError): + # bytes is a metadata primitive but not JSON-serializable. return None return None diff --git a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py index 43220e5f8b..d78a4144d2 100644 --- a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py +++ b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py @@ -11,6 +11,13 @@ These are unit tests over the sanitizer: they need no network and no cassette. """ +import datetime +import json +import uuid +from decimal import Decimal +from enum import Enum +from pathlib import PurePosixPath + from opentelemetry.semconv_ai import SpanAttributes from opentelemetry.instrumentation.langchain.callback_handler import ( @@ -48,15 +55,34 @@ def test_falsy_primitives_are_preserved_not_dropped(): assert _sanitize_metadata_value("") == "" +class _Tier(Enum): + """A caller-defined enum, the kind that shows up as a metadata label.""" + + GOLD = "gold" + + +def test_stdlib_scalars_are_stringified_not_dropped(): + """A UUID session_id, a timestamp, a Decimal, an Enum carry no credential.""" + session_id = uuid.uuid4() + assert _sanitize_metadata_value(session_id) == str(session_id) + assert _sanitize_metadata_value(datetime.datetime(2026, 9, 16, 12, 0)) == ( + "2026-09-16 12:00:00" + ) + assert _sanitize_metadata_value(Decimal("3.14")) == "3.14" + assert _sanitize_metadata_value(_Tier.GOLD) == str(_Tier.GOLD) + assert _sanitize_metadata_value(PurePosixPath("/tmp/x")) == "/tmp/x" + + def test_object_is_dropped_not_stringified(): """The leak: an object's repr must never become the attribute value.""" assert _sanitize_metadata_value(_ClientLikeObject(MARKER)) is None def test_object_inside_a_list_is_dropped(): - """A sequence keeps its primitive elements and loses its object elements.""" - value = _sanitize_metadata_value(["ok", _ClientLikeObject(MARKER)]) - assert value == ["ok"] + """A sequence keeps its scalar elements and loses its object elements.""" + session_id = uuid.uuid4() + value = _sanitize_metadata_value(["ok", session_id, _ClientLikeObject(MARKER)]) + assert value == ["ok", str(session_id)] assert MARKER not in str(value) @@ -66,10 +92,20 @@ def test_plain_dict_is_kept_as_json(): assert value == '{"tenant": "acme", "retries": 2}' -def test_dict_holding_an_object_is_dropped(): - """A mapping is only kept when every value in it is plain data.""" - value = _sanitize_metadata_value({"client": _ClientLikeObject(MARKER)}) - assert value is None +def test_dict_loses_only_its_object_keys(): + """One bad key must not discard its siblings, as the list branch doesn't.""" + session_id = uuid.uuid4() + value = json.loads( + _sanitize_metadata_value( + {"tenant": "acme", "sid": session_id, "client": _ClientLikeObject(MARKER)} + ) + ) + assert value == {"tenant": "acme", "sid": str(session_id)} + + +def test_dict_of_only_objects_is_dropped(): + """Nothing left to record means no attribute, not an empty one.""" + assert _sanitize_metadata_value({"client": _ClientLikeObject(MARKER)}) is None def test_object_never_reaches_association_properties(instrument_legacy, span_exporter): From ff5566a2acd610ae7ee2cb2489078e79cf04a2f8 Mon Sep 17 00:00:00 2001 From: Ido Gol Date: Thu, 17 Sep 2026 15:48:59 +0300 Subject: [PATCH 5/6] fix(langchain): keep a mapping's JSON-safe keys when a sibling is bytes bytes is a metadata primitive but not JSON-serializable, so json.dumps raised on {"tenant": "acme", "payload": b"x"} and the except returned None, taking the safe tenant value down with the unsupported one -- the same sibling-discards-sibling behaviour the per-key filter was meant to end. Filter dict values to what JSON can carry rather than catching the failure afterwards, which makes the try/except unreachable. --- .../instrumentation/langchain/callback_handler.py | 11 ++++------- .../tests/test_metadata_sanitization.py | 6 ++++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py index 21ed8d8205..be8cda64cd 100644 --- a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py @@ -149,17 +149,14 @@ def _sanitize_metadata_value(value: Any) -> Any: return [str(v) for v in value if _metadata_scalar(v) is not None] if isinstance(value, dict): # A mapping of plain data is legitimate metadata, so keep it as JSON. - # Drop only the object-valued keys, the way the list branch does. + # Drop only the keys JSON cannot carry, the way the list branch does -- + # bytes is a metadata primitive but is not JSON-serializable. kept = { key: scalar for key, item in value.items() - if (scalar := _metadata_scalar(item)) is not None + if isinstance(scalar := _metadata_scalar(item), (bool, str, int, float)) } - try: - return json.dumps(kept) if kept else None - except (TypeError, ValueError): - # bytes is a metadata primitive but not JSON-serializable. - return None + return json.dumps(kept) if kept else None return None diff --git a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py index d78a4144d2..3cb0d4861f 100644 --- a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py +++ b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py @@ -103,6 +103,12 @@ def test_dict_loses_only_its_object_keys(): assert value == {"tenant": "acme", "sid": str(session_id)} +def test_dict_loses_only_its_unserializable_keys(): + """bytes is a metadata primitive but not JSON: it must not take siblings down.""" + value = json.loads(_sanitize_metadata_value({"tenant": "acme", "payload": b"x"})) + assert value == {"tenant": "acme"} + + def test_dict_of_only_objects_is_dropped(): """Nothing left to record means no attribute, not an empty one.""" assert _sanitize_metadata_value({"client": _ClientLikeObject(MARKER)}) is None From 8dcda099de5df40af52352901a16ca499c1912ac Mon Sep 17 00:00:00 2001 From: Ido Gol Date: Thu, 17 Sep 2026 15:55:07 +0300 Subject: [PATCH 6/6] fix(langchain): drop mapping keys JSON cannot carry json.dumps rejects a non-string key, and this runs before tracer.start_span inside a @dont_throw caller -- so one tuple key in a metadata mapping was swallowed as a failed callback and the chain produced no span at all, not just a missing attribute. Constrain keys the way values are already constrained rather than catching the failure, which keeps the single serialization. --- .../instrumentation/langchain/callback_handler.py | 8 +++++--- .../tests/test_metadata_sanitization.py | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py index be8cda64cd..423777ace5 100644 --- a/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py @@ -149,12 +149,14 @@ def _sanitize_metadata_value(value: Any) -> Any: return [str(v) for v in value if _metadata_scalar(v) is not None] if isinstance(value, dict): # A mapping of plain data is legitimate metadata, so keep it as JSON. - # Drop only the keys JSON cannot carry, the way the list branch does -- - # bytes is a metadata primitive but is not JSON-serializable. + # Drop the entries JSON cannot carry, the way the list branch does: a + # non-string key or a bytes value would raise, and this runs before the + # span is started, so the whole chain would go untraced over one key. kept = { key: scalar for key, item in value.items() - if isinstance(scalar := _metadata_scalar(item), (bool, str, int, float)) + if isinstance(key, str) + and isinstance(scalar := _metadata_scalar(item), (bool, str, int, float)) } return json.dumps(kept) if kept else None return None diff --git a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py index 3cb0d4861f..12a7b6b821 100644 --- a/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py +++ b/packages/opentelemetry-instrumentation-langchain/tests/test_metadata_sanitization.py @@ -109,6 +109,13 @@ def test_dict_loses_only_its_unserializable_keys(): assert value == {"tenant": "acme"} +def test_dict_with_an_unserializable_key_does_not_raise(): + """This runs before the span starts: a raise here loses the whole trace.""" + value = json.loads(_sanitize_metadata_value({("tenant",): "acme", "ok": 1})) + assert value == {"ok": 1} + assert _sanitize_metadata_value({("tenant",): "acme"}) is None + + def test_dict_of_only_objects_is_dropped(): """Nothing left to record means no attribute, not an empty one.""" assert _sanitize_metadata_value({"client": _ClientLikeObject(MARKER)}) is None