Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from opentelemetry.instrumentation.langchain.version import __version__
from opentelemetry.instrumentation.utils import unwrap
from opentelemetry.metrics import get_meter
from opentelemetry.semconv_ai import Meters, SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY
from opentelemetry.semconv_ai import Meters, SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, SpanAttributes
from opentelemetry.trace import get_tracer
from opentelemetry.trace.propagation import set_span_in_context
from opentelemetry.trace.propagation.tracecontext import (
Expand All @@ -37,10 +37,23 @@ def __init__(
exception_logger=None,
disable_trace_context_propagation=False,
use_legacy_attributes: bool = True,
metadata_key_prefix: str = SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES
):
"""Create a Langchain instrumentor instance.

Args:
exception_logger: A callable that takes an Exception as input. This will be
used to log exceptions that occur during instrumentation. If None, exceptions will not be logged.
disable_trace_context_propagation: If True, disables trace context propagation to LLM providers.
use_legacy_attributes: If True, uses span attributes for Inputs/Outputs instead of events.
metadata_key_prefix: Prefix for metadata keys added to spans. Defaults to
`SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES`.
Useful for using with other backends.
"""
super().__init__()
Config.exception_logger = exception_logger
Config.use_legacy_attributes = use_legacy_attributes
Config.metadata_key_prefix = metadata_key_prefix
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Potential issue: Class variable causes instance configuration conflict.

Config.metadata_key_prefix is a class variable shared across all instances. If multiple LangchainInstrumentor instances are created with different metadata_key_prefix values, they will overwrite each other, causing unexpected behavior where one instance's configuration affects another's.

Example scenario:

# Instance A with custom prefix
instrumentor_a = LangchainInstrumentor(metadata_key_prefix="langfuse.metadata")
instrumentor_a.instrument()

# Instance B with default prefix - overwrites A's configuration
instrumentor_b = LangchainInstrumentor()  
instrumentor_b.instrument()

# Now A's spans use the default prefix instead of "langfuse.metadata"

Consider whether:

  1. Multiple instrumentor instances are a supported use case
  2. If yes, store metadata_key_prefix as an instance variable and pass it to the callback handler
  3. If no, document that only one instrumentor instance should exist per process

Run this script to check if the codebase or documentation discusses multiple instrumentor instances:


🏁 Script executed:

#!/bin/bash
# Search for patterns indicating multiple instrumentor instances or singleton usage

echo "=== Searching for multiple instrumentor instance patterns ==="
rg -n -C3 "LangchainInstrumentor\(\)" --type=py

echo -e "\n=== Checking for singleton or instance management patterns ==="
rg -n -C3 "instrumentor.*=.*LangchainInstrumentor" --type=py

echo -e "\n=== Looking for documentation about instrumentation usage ==="
fd -e md -e rst | xargs rg -l "LangchainInstrumentor"

Length of output: 2500


I need to examine the Config class and understand how metadata_key_prefix is actually used to verify if the class variable issue poses a real problem.

Now I need to examine the actual code to understand how Config.metadata_key_prefix is used and whether the class variable design actually causes problems.


🏁 Script executed:

#!/bin/bash
# Examine Config class and metadata_key_prefix usage

echo "=== Finding Config class definition ==="
fd -e py -type f | xargs grep -l "class Config" | grep -E "(config|instrumentation/langchain)" | head -5

echo -e "\n=== Looking at Config class ==="
find . -path "*instrumentation/langchain*" -name "*.py" -type f | xargs grep -l "class Config" | head -1 | xargs cat

echo -e "\n=== Searching for metadata_key_prefix usage ==="
rg -n "metadata_key_prefix" --type=py | head -20

Length of output: 1512


I need to examine how the callback handler is instantiated and whether it captures metadata_key_prefix at creation time or reads it dynamically.


🏁 Script executed:

#!/bin/bash
# Examine callback handler instantiation and metadata_key_prefix usage

echo "=== Looking for callback handler instantiation in __init__.py ==="
rg -n -A10 -B5 "callback_handler" packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/__init__.py | head -40

echo -e "\n=== Checking callback_handler.py around line 294 ==="
sed -n '280,310p' packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

echo -e "\n=== Looking at full callback handler initialization ==="
rg -n "class.*CallbackHandler" packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/callback_handler.py

echo -e "\n=== Checking if metadata_key_prefix is passed to callback handler ==="
rg -n -B5 -A5 "CallbackHandler\(" packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/__init__.py

Length of output: 3660


Now let me check the README to understand the intended usage pattern and whether multiple instances are documented as supported or unsupported.


🏁 Script executed:

#!/bin/bash
# Check README for usage documentation

echo "=== Reading LangchainInstrumentor README ==="
cat packages/opentelemetry-instrumentation-langchain/README.md

Length of output: 1180


Store metadata_key_prefix as an instance variable to prevent configuration conflicts.

The issue is confirmed: Config.metadata_key_prefix is a class variable read dynamically by the callback handler at span creation time (callback_handler.py line 294). Multiple LangchainInstrumentor instances with different metadata_key_prefix values will overwrite each other's configuration via line 55 in __init__.py.

Currently, the codebase and README show only single-instance usage (LangchainInstrumentor().instrument()), and no concurrent multi-instance scenarios exist. However, this design is a latent footgun: if future code creates multiple instances with different prefixes, spans will silently use the wrong prefix.

Recommended fix: Pass metadata_key_prefix to TraceloopCallbackHandler at instantiation (line 89) and have it store/use the instance-specific value instead of reading from the shared Config class variable. Alternatively, document explicitly that only one LangchainInstrumentor instance should be active per process.

🤖 Prompt for AI Agents
In
packages/opentelemetry-instrumentation-langchain/opentelemetry/instrumentation/langchain/__init__.py
around line 55 (and referencing the TraceloopCallbackHandler instantiation at
~line 89 and its use at callback_handler.py line 294), the code currently
assigns metadata_key_prefix to the shared Config.class variable which causes
cross-instance config overwrite; change the implementation to store
metadata_key_prefix as an instance attribute on LangchainInstrumentor (not
Config), modify the TraceloopCallbackHandler constructor call at line 89 to
accept this metadata_key_prefix and update the TraceloopCallbackHandler to store
and use the passed instance-specific prefix instead of reading
Config.metadata_key_prefix at runtime (callback creation/usage around line 294).

self.disable_trace_context_propagation = disable_trace_context_propagation

def instrumentation_dependencies(self) -> Collection[str]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
LLMResult,
)
from opentelemetry import context as context_api
from opentelemetry.instrumentation.langchain.config import Config
from opentelemetry.instrumentation.langchain.event_emitter import emit_event
from opentelemetry.instrumentation.langchain.event_models import (
ChoiceEvent,
Expand Down Expand Up @@ -290,7 +291,7 @@ def _create_span(
for key, value in sanitized_metadata.items():
_set_span_attribute(
span,
f"{SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.{key}",
f"{Config.metadata_key_prefix}.{key}",
value,
)

Expand Down Expand Up @@ -752,7 +753,7 @@ def _handle_error(
return

span = self._get_span(run_id)
span.set_status(Status(StatusCode.ERROR))
span.set_status(Status(StatusCode.ERROR), str(error))
span.record_exception(error)
self._end_span(span, run_id)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from typing import Optional

from opentelemetry._logs import Logger
from opentelemetry.semconv_ai import SpanAttributes


class Config:
exception_logger = None
use_legacy_attributes = True
event_logger: Optional[Logger] = None
metadata_key_prefix: str = SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ def set_chat_response_usage(
)
_set_span_attribute(
span,
SpanAttributes.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS,
SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS,
cache_read_tokens,
)
if record_token_usage:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,7 @@ async def test_astream_with_events_with_content(
assert len(chunks) == 144

logs = log_exporter.get_finished_logs()
assert len(logs) == 1
assert len(logs) == 2

# Validate user message Event
assert_message_in_logs(
Expand Down Expand Up @@ -802,7 +802,7 @@ async def test_astream_with_events_with_no_content(
assert len(chunks) == 144

logs = log_exporter.get_finished_logs()
assert len(logs) == 1
assert len(logs) == 2

# Validate user message Event
assert_message_in_logs(logs[0], "gen_ai.user.message", {})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def test_sequential_chain_with_events_with_content(
] == [span.name for span in spans]

logs = log_exporter.get_finished_logs()
assert len(logs) == 1
assert len(logs) == 2

# Validate user message Event
assert_message_in_logs(
Expand All @@ -107,12 +107,12 @@ def test_sequential_chain_with_events_with_content(
)

# Validate AI choice Event
# _choice_event = {
# "index": 0,
# "finish_reason": "unknown",
# "message": {"content": response["output_text"]},
# }
# assert_message_in_logs(logs[1], "gen_ai.choice", _choice_event)
_choice_event = {
"index": 0,
"finish_reason": "unknown",
"message": {"content": response["output_text"]},
}
assert_message_in_logs(logs[1], "gen_ai.choice", _choice_event)


@pytest.mark.vcr
Expand All @@ -139,14 +139,14 @@ def test_sequential_chain_with_events_with_no_content(
] == [span.name for span in spans]

logs = log_exporter.get_finished_logs()
assert len(logs) == 1
assert len(logs) == 2

# Validate user message Event
assert_message_in_logs(logs[0], "gen_ai.user.message", {})

# Validate AI choice Event
# choice_event = {"index": 0, "finish_reason": "unknown", "message": {}}
# assert_message_in_logs(logs[1], "gen_ai.choice", choice_event)
choice_event = {"index": 0, "finish_reason": "unknown", "message": {}}
assert_message_in_logs(logs[1], "gen_ai.choice", choice_event)


def assert_message_in_logs(log: LogData, event_name: str, expected_content: dict):
Expand Down
132 changes: 66 additions & 66 deletions packages/opentelemetry-instrumentation-langchain/tests/test_lcel.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ class Joke(BaseModel):
assert output_parser_task_span.parent.span_id == workflow_span.context.span_id

logs = log_exporter.get_finished_logs()
assert len(logs) == 2
assert len(logs) == 3

# Validate system message Event
assert_message_in_logs(
Expand All @@ -198,23 +198,23 @@ class Joke(BaseModel):
)

# Validate AI choice Event
# _choice_event = {
# "index": 0,
# "finish_reason": "function_call",
# "message": {"content": ""},
# "tool_calls": [
# {
# "id": "",
# "function": {
# "name": "Joke",
# "arguments": '{"setup":"Why couldn\'t the bicycle stand up by itself?","punchline":"It was two '
# 'tired!"}',
# },
# "type": "function",
# }
# ],
# }
# assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)
_choice_event = {
"index": 0,
"finish_reason": "function_call",
"message": {"content": ""},
"tool_calls": [
{
"id": "",
"function": {
"name": "Joke",
"arguments": '{"setup":"Why couldn\'t the bicycle stand up by itself?","punchline":"It was two '
'tired!"}',
},
"type": "function",
}
],
}
assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)


@pytest.mark.vcr
Expand Down Expand Up @@ -269,7 +269,7 @@ class Joke(BaseModel):
assert output_parser_task_span.parent.span_id == workflow_span.context.span_id

logs = log_exporter.get_finished_logs()
assert len(logs) == 2
assert len(logs) == 3

# Validate system message Event
assert_message_in_logs(logs[0], "gen_ai.system.message", {})
Expand All @@ -278,13 +278,13 @@ class Joke(BaseModel):
assert_message_in_logs(logs[1], "gen_ai.user.message", {})

# Validate AI choice Event
# _choice_event = {
# "index": 0,
# "finish_reason": "function_call",
# "message": {},
# "tool_calls": [{"function": {"name": "Joke"}, "id": "", "type": "function"}],
# }
# assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)
_choice_event = {
"index": 0,
"finish_reason": "function_call",
"message": {},
"tool_calls": [{"function": {"name": "Joke"}, "id": "", "type": "function"}],
}
assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)


@pytest.mark.vcr
Expand Down Expand Up @@ -382,7 +382,7 @@ async def test_async_lcel_with_events_with_content(
assert output_parser_task_span.parent.span_id == workflow_span.context.span_id

logs = log_exporter.get_finished_logs()
assert len(logs) == 1
assert len(logs) == 2

# Validate user message Event
assert_message_in_logs(
Expand All @@ -394,12 +394,12 @@ async def test_async_lcel_with_events_with_content(
assert response != ""

# Validate AI choice Event
# _choice_event = {
# "index": 0,
# "finish_reason": "stop",
# "message": {"content": response},
# }
# assert_message_in_logs(logs[1], "gen_ai.choice", _choice_event)
_choice_event = {
"index": 0,
"finish_reason": "stop",
"message": {"content": response},
}
assert_message_in_logs(logs[1], "gen_ai.choice", _choice_event)


@pytest.mark.vcr
Expand Down Expand Up @@ -441,18 +441,18 @@ async def test_async_lcel_with_events_with_no_content(
assert output_parser_task_span.parent.span_id == workflow_span.context.span_id

logs = log_exporter.get_finished_logs()
assert len(logs) == 1
assert len(logs) == 2

# Validate user message Event
assert_message_in_logs(logs[0], "gen_ai.user.message", {})

# Validate AI choice Event
# _choice_event = {
# "index": 0,
# "finish_reason": "stop",
# "message": {},
# }
# assert_message_in_logs(logs[1], "gen_ai.choice", _choice_event)
_choice_event = {
"index": 0,
"finish_reason": "stop",
"message": {},
}
assert_message_in_logs(logs[1], "gen_ai.choice", _choice_event)


@pytest.mark.vcr
Expand Down Expand Up @@ -907,7 +907,7 @@ class Joke(BaseModel):
) == set([span.name for span in spans])

logs = log_exporter.get_finished_logs()
assert len(logs) == 2
assert len(logs) == 3

# Validate system message Event
assert_message_in_logs(
Expand All @@ -920,23 +920,23 @@ class Joke(BaseModel):
)

# Validate AI choice Event
# _choice_event = {
# "index": 0,
# "finish_reason": "function_call",
# "message": {"content": ""},
# "tool_calls": [
# {
# "id": "",
# "function": {
# "name": "Joke",
# "arguments": '{"setup":"Why couldn\'t the bicycle stand up by '
# 'itself?","punchline":"Because it was two tired!"}',
# },
# "type": "function",
# }
# ],
# }
# assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)
_choice_event = {
"index": 0,
"finish_reason": "function_call",
"message": {"content": ""},
"tool_calls": [
{
"id": "",
"function": {
"name": "Joke",
"arguments": '{"setup":"Why couldn\'t the bicycle stand up by '
'itself?","punchline":"Because it was two tired!"}',
},
"type": "function",
}
],
}
assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)


@pytest.mark.vcr
Expand Down Expand Up @@ -983,7 +983,7 @@ class Joke(BaseModel):
) == set([span.name for span in spans])

logs = log_exporter.get_finished_logs()
assert len(logs) == 2
assert len(logs) == 3

# Validate system message Event
assert_message_in_logs(logs[0], "gen_ai.system.message", {})
Expand All @@ -992,13 +992,13 @@ class Joke(BaseModel):
assert_message_in_logs(logs[1], "gen_ai.user.message", {})

# Validate AI choice Event
# _choice_event = {
# "index": 0,
# "finish_reason": "function_call",
# "message": {},
# "tool_calls": [{"function": {"name": "Joke"}, "id": "", "type": "function"}],
# }
# assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)
_choice_event = {
"index": 0,
"finish_reason": "function_call",
"message": {},
"tool_calls": [{"function": {"name": "Joke"}, "id": "", "type": "function"}],
}
assert_message_in_logs(logs[2], "gen_ai.choice", _choice_event)


def assert_message_in_logs(log: LogData, event_name: str, expected_content: dict):
Expand Down
Loading