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
19 changes: 19 additions & 0 deletions samples/langchain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ Demonstrates the internal langchain instrumentation.
| `OTEL_SEMCONV_STABILITY_OPT_IN` | "gen_ai_latest_experimental" |
| `AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING` | "true" |

> **Alternative** Instead of setting the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` and `OTEL_SEMCONV_STABILITY_OPT_IN` environment variables, pass the config `enable_sensitive_data=True` to `use_microsoft_opentelemetry()`:

```python
use_microsoft_opentelemetry(
enable_sensitive_data=True,
...
)
```

When `enable_sensitive_data=True` is supplied:

- Sensitive and experimental data attributes populate on the spans.
Comment thread
rads-1996 marked this conversation as resolved.
- The content capture mode defaults to `SPAN_AND_EVENT`.
- This setting takes **precedence over** the pre-existing values of the corresponding environment variables.

> **Note:** `enable_sensitive_data` defaults to `False`. Only enable it in trusted, non-production environments where capturing message content is intentional. This configuration currently applies only to LangChain instrumentation and Microsoft Agent Framework.

---

**Placeholders to fill: If use azure endpoint and api key**

| Placeholder | Value |
Expand Down
4 changes: 2 additions & 2 deletions src/microsoft/opentelemetry/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
# --- Microsoft OpenTelemetry Constants ---

ENABLE_AZURE_MONITOR_ARG = "enable_azure_monitor"
ENABLE_SENSITIVE_DATA_ARG = "enable_sensitive_data"


# --- OTLP Environment Variable Constants ---

Expand All @@ -88,8 +90,6 @@

# --- Spectra Sidecar Constants ---

ENABLE_SENSITIVE_DATA_ARG = "enable_sensitive_data"

ENABLE_SPECTRA_ARG = "enable_spectra"
SPECTRA_ENDPOINT_ARG = "spectra_endpoint"
SPECTRA_PROTOCOL_ARG = "spectra_protocol"
Expand Down
2 changes: 1 addition & 1 deletion src/microsoft/opentelemetry/_distro.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ def _setup_instrumentations(otel_kwargs: Dict[str, Any], **kwargs: Any) -> None:
continue
lib_kwargs = _get_instrumentation_kwargs(otel_kwargs, lib_name)
merged_kwargs = {**kwargs, **lib_kwargs}
if lib_name == "agent_framework":
if lib_name in ["agent_framework", "langchain"]:
merged_kwargs[ENABLE_SENSITIVE_DATA_ARG] = enable_sensitive_data
instrumentor: Any = entry_point.load()
instrumentor().instrument(skip_dep_check=True, **merged_kwargs)
Expand Down
18 changes: 11 additions & 7 deletions src/microsoft/opentelemetry/_genai/_langchain/_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class LangChainTracer(BaseTracer): # pylint: disable=too-many-ancestors, too-ma
"_spans_by_run",
"_event_logger",
"_context_tokens",
"_enable_sensitive_data",
)

def __init__(
Expand All @@ -126,6 +127,7 @@ def __init__(
*args: Any,
agent_config: dict[str, Any] | None = None,
event_logger: Any | None = None,
enable_sensitive_data: bool = False,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
Expand All @@ -141,6 +143,7 @@ def __init__(
self._event_logger = event_logger
self._context_tokens: dict[UUID, list[Token]] = {}
self._lock = RLock() # type: ignore[misc]
self._enable_sensitive_data = enable_sensitive_data

def get_span(self, run_id: UUID) -> Span | None:
with self._lock:
Expand Down Expand Up @@ -298,9 +301,10 @@ def _end_trace(self, run: Run) -> None:
invocation: LLMInvocation | None = None
try:
if is_agent:
# Single-span agent: finalize directly
self._finalize_agent_span(span, run)
else:
invocation = _update_span(span, run)
invocation = _update_span(span, run, self._enable_sensitive_data)
except Exception:
logger.exception("Failed to update span with run data.")
# Emit OTel GenAI event for LLM spans (respects env-var config)
Expand Down Expand Up @@ -622,7 +626,7 @@ def _finalize_agent_span(self, span: Span, run: Run) -> None:
span.set_attribute(GEN_AI_USAGE_OUTPUT_TOKENS_KEY, output_tokens)

# Set aggregated input/output messages only when content capture is enabled
if _should_capture_content_on_spans():
if _should_capture_content_on_spans(self._enable_sensitive_data):
if tool_defs := content.get("tool_definitions"):
span.set_attribute(GEN_AI_TOOL_DEFINITIONS_KEY, tool_defs)
if msgs := content.get("input_messages"):
Expand Down Expand Up @@ -661,7 +665,7 @@ def get_attributes_from_context() -> Iterator[tuple[str, AttributeValue]]:
yield ctx_attr, cast(AttributeValue, val)


def _update_span(span: Span, run: Run) -> LLMInvocation | None:
def _update_span(span: Span, run: Run, enable_sensitive_data: bool = False) -> LLMInvocation | None:
Comment thread
hectorhdzg marked this conversation as resolved.
"""Update a non-agent span with run data.

Returns the ``LLMInvocation`` for LLM runs (used for event emission
Expand Down Expand Up @@ -691,7 +695,7 @@ def _update_span(span: Span, run: Run) -> LLMInvocation | None:
chain(
prompts(run.inputs),
invocation_parameters(run),
function_calls(run.outputs),
function_calls(run.outputs, enable_sensitive_data),
metadata(run),
)
)
Expand All @@ -705,9 +709,9 @@ def _update_span(span: Span, run: Run) -> LLMInvocation | None:
flatten(
chain(
add_operation_type(run),
chain_node_messages(run.inputs, GEN_AI_INPUT_MESSAGES_KEY),
chain_node_messages(run.outputs, GEN_AI_OUTPUT_MESSAGES_KEY),
tools(run),
chain_node_messages(run.inputs, GEN_AI_INPUT_MESSAGES_KEY, enable_sensitive_data),
chain_node_messages(run.outputs, GEN_AI_OUTPUT_MESSAGES_KEY, enable_sensitive_data),
tools(run, enable_sensitive_data),
metadata(run),
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ def _instrument(self, **kwargs: Any) -> None:
tracer_provider=tracer_provider,
)

enable_sensitive_data = kwargs.get("enable_sensitive_data", False)

logger_provider = kwargs.get("logger_provider")
event_logger = get_otel_logger(
__name__,
Expand All @@ -88,6 +90,7 @@ def _instrument(self, **kwargs: Any) -> None:
bool(kwargs.get("separate_trace_from_runtime_context")),
agent_config=agent_config,
event_logger=event_logger,
enable_sensitive_data=enable_sensitive_data,
)

self._original_cb_init = langchain_core.callbacks.BaseCallbackManager.__init__
Expand Down
16 changes: 9 additions & 7 deletions src/microsoft/opentelemetry/_genai/_langchain/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@
# ---- Core utilities ----------------------------------------------------------


def _should_capture_content_on_spans() -> bool:
def _should_capture_content_on_spans(enable_sensitive_data: bool = False) -> bool:
"""Check if content should be captured on span attributes."""
if enable_sensitive_data:
return True
if not is_experimental_mode():
return False
mode = get_content_capturing_mode()
Expand Down Expand Up @@ -830,7 +832,7 @@ def _parse_token_usage(outputs: Mapping[str, Any] | None) -> Any:


@stop_on_exception
def function_calls(outputs: Mapping[str, Any] | None) -> Iterator[tuple[str, str]]:
def function_calls(outputs: Mapping[str, Any] | None, enable_sensitive_data: bool = False) -> Iterator[tuple[str, str]]:
if not outputs:
return
if not isinstance(outputs, Mapping):
Expand All @@ -851,7 +853,7 @@ def function_calls(outputs: Mapping[str, Any] | None) -> Iterator[tuple[str, str
call_id = fc.get("id")
if isinstance(call_id, str):
yield GEN_AI_TOOL_CALL_ID_KEY, call_id
if _should_capture_content_on_spans():
if _should_capture_content_on_spans(enable_sensitive_data):
args = fc.get("arguments")
if args is not None:
if isinstance(args, str):
Expand All @@ -868,7 +870,7 @@ def function_calls(outputs: Mapping[str, Any] | None) -> Iterator[tuple[str, str


@stop_on_exception
def tools(run: Run) -> Iterator[tuple[str, str]]:
def tools(run: Run, enable_sensitive_data: bool = False) -> Iterator[tuple[str, str]]:
if run.run_type.lower() != "tool":
return
if not (serialized := run.serialized):
Expand All @@ -883,7 +885,7 @@ def tools(run: Run) -> Iterator[tuple[str, str]]:
if run.extra and hasattr(run.extra, "get"):
if tool_call_id := run.extra.get("tool_call_id"):
yield GEN_AI_TOOL_CALL_ID_KEY, tool_call_id
if _should_capture_content_on_spans():
if _should_capture_content_on_spans(enable_sensitive_data):
if run.inputs and hasattr(run.inputs, "get"):
_sentinel = object()
input_val = run.inputs.get("input", _sentinel)
Expand All @@ -910,12 +912,13 @@ def tools(run: Run) -> Iterator[tuple[str, str]]:
def chain_node_messages(
data: Mapping[str, Any] | None,
attr_key: str,
enable_sensitive_data: bool = False,
) -> Iterator[tuple[str, str]]:
"""Extract messages from a LangGraph chain node's inputs or outputs.

Chain nodes typically store messages as ``{"messages": [BaseMessage, ...]}``.
"""
if not _should_capture_content_on_spans():
if not _should_capture_content_on_spans(enable_sensitive_data):
return
if not data or not isinstance(data, Mapping):
return
Expand Down Expand Up @@ -1559,7 +1562,6 @@ def _output_message_to_input(out_msg: OutputMessage) -> InputMessage:
return InputMessage(role=out_msg.role, parts=list(out_msg.parts))



@stop_on_exception
def invoke_agent_input_message(
inputs: Mapping[str, Any] | None,
Expand Down
37 changes: 32 additions & 5 deletions tests/langchain/test_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ class TestUpdateSpan(TestCase):
def test_sets_ok_status_on_no_error(self):
span = MagicMock()
run = _make_run(run_type="chain", name="test", error=None)
_update_span(span, run)
_update_span(span, run, False)
span.set_status.assert_called()

def test_llm_run_returns_invocation(self):
Expand All @@ -628,13 +628,13 @@ def test_llm_run_returns_invocation(self):
extra=None,
inputs=None,
)
result = _update_span(span, run)
result = _update_span(span, run, False)
self.assertIsNotNone(result)

def test_chain_run_returns_none(self):
span = MagicMock()
run = _make_run(run_type="chain", name="test")
result = _update_span(span, run)
result = _update_span(span, run, False)
self.assertIsNone(result)

def test_tool_run_sets_tool_attributes(self):
Expand All @@ -646,7 +646,7 @@ def test_tool_run_sets_tool_attributes(self):
inputs={"input": "2+2"},
outputs={"output": "4"},
)
_update_span(span, run)
_update_span(span, run, False)
span.set_attributes.assert_called()

def test_chat_span_sets_provider_and_choice_count(self):
Expand All @@ -664,7 +664,7 @@ def test_chat_span_sets_provider_and_choice_count(self):
inputs=None,
)

_update_span(span, run)
_update_span(span, run, False)

merged_attrs = {}
for call in span.set_attributes.call_args_list:
Expand Down Expand Up @@ -1290,3 +1290,30 @@ def test_tool_role_message_becomes_tool_call_response(self):
self.assertEqual(tool_part.type, "tool_call_response")
self.assertEqual(tool_part.id, "tc1")
self.assertEqual(tool_part.response, "rainy")


# ---- LangChainTracer enable_sensitive_data -----------------------------------


class TestLangChainTracerEnableSensitiveData(TestCase):
def test_default_enable_sensitive_data_is_false(self):
tracer, _, _ = _make_tracer()
self.assertFalse(tracer._enable_sensitive_data)

def test_enable_sensitive_data_stored_when_true(self):
otel_tracer = MagicMock()
tracer = LangChainTracer(
otel_tracer,
False,
enable_sensitive_data=True,
)
self.assertTrue(tracer._enable_sensitive_data)

def test_enable_sensitive_data_stored_when_false(self):
otel_tracer = MagicMock()
tracer = LangChainTracer(
otel_tracer,
False,
enable_sensitive_data=False,
)
self.assertFalse(tracer._enable_sensitive_data)
45 changes: 45 additions & 0 deletions tests/langchain/test_tracer_instrumentor.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,48 @@ def test_does_not_add_duplicate(self):
mock_instance.inheritable_handlers = [mock_tracer]
hook(mock_wrapped, mock_instance, (), {})
mock_instance.add_handler.assert_not_called()


class TestLangChainInstrumentorEnableSensitiveData(TestCase):
def setUp(self):
inst = LangChainInstrumentor()
if inst.is_instrumented_by_opentelemetry:
inst._uninstrument()

def tearDown(self):
inst = LangChainInstrumentor()
if inst.is_instrumented_by_opentelemetry:
inst._uninstrument()

@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.get_otel_logger")
@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.trace_api.get_tracer")
@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.wrap_function_wrapper")
def test_enable_sensitive_data_true_passed_to_tracer(self, mock_wrap, mock_get_tracer, mock_get_logger):
"""When enable_sensitive_data=True, the LangChainTracer receives the flag as True."""
mock_get_tracer.return_value = MagicMock()
mock_get_logger.return_value = MagicMock()
inst = LangChainInstrumentor()
inst._instrument(enable_sensitive_data=True)
self.assertTrue(inst._tracer._enable_sensitive_data)

@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.get_otel_logger")
@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.trace_api.get_tracer")
@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.wrap_function_wrapper")
def test_enable_sensitive_data_defaults_to_false(self, mock_wrap, mock_get_tracer, mock_get_logger):
"""When enable_sensitive_data is not passed, the LangChainTracer defaults to False."""
mock_get_tracer.return_value = MagicMock()
mock_get_logger.return_value = MagicMock()
inst = LangChainInstrumentor()
inst._instrument()
self.assertFalse(inst._tracer._enable_sensitive_data)

@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.get_otel_logger")
@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.trace_api.get_tracer")
@patch("microsoft.opentelemetry._genai._langchain._tracer_instrumentor.wrap_function_wrapper")
def test_enable_sensitive_data_false_explicit(self, mock_wrap, mock_get_tracer, mock_get_logger):
"""When enable_sensitive_data=False explicitly, the LangChainTracer stores False."""
mock_get_tracer.return_value = MagicMock()
mock_get_logger.return_value = MagicMock()
inst = LangChainInstrumentor()
inst._instrument(enable_sensitive_data=False)
self.assertFalse(inst._tracer._enable_sensitive_data)
Loading
Loading