Skip to content

Commit 5efa8ce

Browse files
authored
Fix SDK OpenTelemetry opt-in behavior (#567)
1 parent 10a67bf commit 5efa8ce

3 files changed

Lines changed: 127 additions & 23 deletions

File tree

src/mistralai/client/_hooks/tracing.py

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,29 +44,30 @@ def __init__(self) -> None:
4444
self._auto_telemetry_provider: Optional[Any] = None
4545
self._telemetry_finalizer: Optional[weakref.finalize] = None
4646
self._telemetry_auto_disabled: bool = False
47+
self._telemetry_use_global_provider: bool = False
4748
self.tracing_enabled, self.tracer = get_or_create_otel_tracer()
4849

4950
def before_request(
5051
self, hook_ctx: BeforeRequestContext, request: httpx.Request
5152
) -> Union[httpx.Request, Exception]:
52-
# The GenAI span is created in this hook, but HTTPX creates its own
53-
# auto-instrumented span later inside send(). Wrap the configured
54-
# clients so each request's stored GenAI span is current only while
55-
# that request is being sent.
56-
self._ensure_client_send_wrapped(getattr(hook_ctx.config, "client", None))
57-
self._ensure_async_client_send_wrapped(
58-
getattr(hook_ctx.config, "async_client", None)
59-
)
60-
configure_telemetry_for_hook(
53+
telemetry_configured = configure_telemetry_for_hook(
6154
self,
6255
hook_ctx.config,
6356
respect_global_provider=True,
6457
)
65-
# Refresh tracer/provider per request so tracing can be enabled if the
66-
# application configures OpenTelemetry after the client is instantiated.
67-
self.tracing_enabled, self.tracer = get_or_create_otel_tracer(
68-
provider=self.tracer_provider,
58+
should_trace = (
59+
telemetry_configured
60+
or self.tracer_provider is not None
61+
or self._telemetry_use_global_provider
6962
)
63+
if should_trace:
64+
# Refresh tracer/provider per request so tracing can be enabled if the
65+
# application configures OpenTelemetry after the client is instantiated.
66+
self.tracing_enabled, self.tracer = get_or_create_otel_tracer(
67+
provider=self.tracer_provider,
68+
)
69+
else:
70+
self.tracing_enabled = False
7071
request, span = get_traced_request_and_span(
7172
tracing_enabled=self.tracing_enabled,
7273
tracer=self.tracer,
@@ -75,6 +76,15 @@ def before_request(
7576
request=request,
7677
)
7778
self._store_span_on_request(request, span)
79+
# The GenAI span is created in this hook, but HTTPX creates its own
80+
# auto-instrumented span later inside send(). Wrap the configured
81+
# clients so each request's stored GenAI span is current only while
82+
# that request is being sent.
83+
if span is not None:
84+
self._ensure_client_send_wrapped(getattr(hook_ctx.config, "client", None))
85+
self._ensure_async_client_send_wrapped(
86+
getattr(hook_ctx.config, "async_client", None)
87+
)
7888
return request
7989

8090
@staticmethod

src/mistralai/extra/observability/telemetry.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,9 @@ def configure_telemetry_for_hook(
135135
) -> bool:
136136
"""Configure telemetry for a tracing hook when the user has opted in."""
137137
# Fast path: already resolved and no explicit override requested.
138-
if hook._auto_telemetry_provider is not None and telemetry is None:
138+
if telemetry is None and (
139+
hook._auto_telemetry_provider is not None or hook._telemetry_use_global_provider
140+
):
139141
return True
140142
if telemetry is None and hook._telemetry_auto_disabled:
141143
return False
@@ -155,6 +157,7 @@ def configure_telemetry_for_hook(
155157
)
156158
if provider_mode is None:
157159
_shutdown_telemetry_provider(hook)
160+
hook._telemetry_use_global_provider = False
158161
hook._telemetry_auto_disabled = True
159162
return False
160163

@@ -176,6 +179,7 @@ def configure_telemetry_for_hook(
176179
"configure_telemetry(client, provider='dedicated') to attach an "
177180
"SDK-owned provider for this client."
178181
)
182+
hook._telemetry_use_global_provider = False
179183
hook._telemetry_auto_disabled = True
180184
return False
181185

@@ -294,6 +298,7 @@ def _attach_telemetry_provider(
294298
_shutdown_telemetry_provider(hook)
295299
hook.tracer_provider = provider
296300
hook._auto_telemetry_provider = provider
301+
hook._telemetry_use_global_provider = False
297302
hook._telemetry_auto_disabled = False
298303
hook._telemetry_finalizer = weakref.finalize(
299304
finalizer_owner, provider.shutdown
@@ -306,6 +311,7 @@ def _attach_custom_tracer_provider(
306311
) -> None:
307312
_shutdown_telemetry_provider(hook)
308313
hook.tracer_provider = provider
314+
hook._telemetry_use_global_provider = False
309315
hook._telemetry_auto_disabled = False
310316

311317

@@ -323,6 +329,7 @@ def _use_global_tracer_provider(
323329

324330
_shutdown_telemetry_provider(hook)
325331
hook.tracer_provider = None
332+
hook._telemetry_use_global_provider = True
326333
hook._telemetry_auto_disabled = True
327334
return True
328335

src/mistralai/extra/tests/test_otel_tracing.py

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
from collections import Counter
1616
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1717
import json
18+
import os
1819
import threading
1920
import unittest
2021
from datetime import datetime, timezone
2122
from typing import cast
22-
from unittest.mock import MagicMock
23+
from unittest.mock import MagicMock, patch
2324

2425
import httpx
2526
from opentelemetry import context as context_api
@@ -118,9 +119,16 @@ def _make_httpx_response(body: dict, status_code: int = 200) -> httpx.Response:
118119
return resp
119120

120121

121-
def _make_hook_context(operation_id: str) -> HookContext:
122+
def _make_hook_context(
123+
operation_id: str,
124+
telemetry: bool | str | None = "global",
125+
) -> HookContext:
126+
config = MagicMock()
127+
config.telemetry = telemetry
128+
config.client = None
129+
config.async_client = None
122130
return HookContext(
123-
config=MagicMock(),
131+
config=config,
124132
base_url="https://api.mistral.ai",
125133
operation_id=operation_id,
126134
oauth2_scopes=None,
@@ -254,6 +262,7 @@ def _run_hook_lifecycle(
254262
request_body,
255263
response_body,
256264
streaming: bool = False,
265+
telemetry: bool | str | None = "global",
257266
):
258267
"""Drive the real TracingHook: before_request → after_success.
259268
@@ -266,7 +275,7 @@ def _run_hook_lifecycle(
266275
so the span is finalised before returning.
267276
"""
268277
hook = TracingHook()
269-
hook_ctx = _make_hook_context(operation_id)
278+
hook_ctx = _make_hook_context(operation_id, telemetry=telemetry)
270279

271280
req_dict = (
272281
_dump(request_body) if hasattr(request_body, "model_dump") else request_body
@@ -309,10 +318,11 @@ def _run_hook_error_lifecycle(
309318
response_body: dict,
310319
status_code: int = 400,
311320
error: Exception | None = None,
321+
telemetry: bool | str | None = "global",
312322
):
313323
"""Drive the real TracingHook: before_request → after_error."""
314324
hook = TracingHook()
315-
hook_ctx = _make_hook_context(operation_id)
325+
hook_ctx = _make_hook_context(operation_id, telemetry=telemetry)
316326

317327
req_dict = (
318328
_dump(request_body) if hasattr(request_body, "model_dump") else request_body
@@ -343,6 +353,36 @@ def assertSpanAttributes(self, span, expected: dict):
343353
actual = {k: span.attributes[k] for k in expected}
344354
self.assertEqual(expected, actual)
345355

356+
def test_global_provider_alone_does_not_enable_sdk_span(self):
357+
request = ChatCompletionRequest(
358+
model="mistral-small-latest",
359+
messages=[UserMessage(content="Hello")],
360+
)
361+
response = ChatCompletionResponse(
362+
id="cmpl-no-telemetry-env",
363+
object="chat.completion",
364+
model="mistral-small-latest",
365+
created=1700000015,
366+
choices=[
367+
ChatCompletionChoice(
368+
index=0,
369+
message=AssistantMessage(content="Hi!", tool_calls=None),
370+
finish_reason="stop",
371+
),
372+
],
373+
usage=UsageInfo(prompt_tokens=5, completion_tokens=2, total_tokens=7),
374+
)
375+
376+
with patch.dict(os.environ, {}, clear=True):
377+
self._run_hook_lifecycle(
378+
"chat_completion_v1_chat_completions_post",
379+
request,
380+
response,
381+
telemetry=None,
382+
)
383+
384+
self.assertEqual(len(self._get_finished_spans()), 0)
385+
346386
# -- Simple chat completion ------------------------------------------------
347387

348388
def test_simple_chat_completion(self):
@@ -1754,6 +1794,7 @@ async def _mock_handler(request: httpx.Request) -> httpx.Response:
17541794
api_key="test-key",
17551795
async_client=async_client,
17561796
)
1797+
client.sdk_configuration.__dict__["telemetry"] = "global"
17571798

17581799
async def _run():
17591800
return await asyncio.gather(
@@ -1804,6 +1845,48 @@ async def _run():
18041845

18051846
# -- HTTPX auto-instrumentation parenting ---------------------------------
18061847

1848+
def test_app_otel_does_not_enable_mistral_span_without_mistral_telemetry(self):
1849+
instrumentor = HTTPXClientInstrumentor()
1850+
instrumentor.instrument()
1851+
tracer = trace.get_tracer("test-workflow-parenting")
1852+
1853+
try:
1854+
with patch.dict(os.environ, {}, clear=True):
1855+
with (
1856+
_ChatCompletionTestServer() as server,
1857+
httpx.Client() as http_client,
1858+
):
1859+
client = Mistral(
1860+
api_key="test-key",
1861+
client=http_client,
1862+
server_url=server.url,
1863+
)
1864+
with tracer.start_as_current_span(
1865+
"ExecuteActivity:generate_site_diagnostic"
1866+
) as activity_span:
1867+
client.chat.complete(
1868+
model="mistral-small-latest",
1869+
messages=_make_user_messages("hello"),
1870+
)
1871+
1872+
self.assertEqual(
1873+
trace.get_current_span().get_span_context().span_id,
1874+
activity_span.get_span_context().span_id,
1875+
)
1876+
finally:
1877+
instrumentor.uninstrument()
1878+
1879+
spans = self._get_finished_spans()
1880+
activity = next(
1881+
s for s in spans if s.name == "ExecuteActivity:generate_site_diagnostic"
1882+
)
1883+
genai_spans = [s for s in spans if s.name == "chat mistral-small-latest"]
1884+
post_spans = [s for s in spans if s.name == "POST"]
1885+
1886+
self.assertEqual(genai_spans, [])
1887+
self.assertEqual(len(post_spans), 1)
1888+
self.assertEqual(post_spans[0].parent.span_id, activity.context.span_id)
1889+
18071890
def test_httpx_auto_instrumented_span_is_child_of_genai_span(self):
18081891
instrumentor = HTTPXClientInstrumentor()
18091892
instrumentor.instrument()
@@ -1816,6 +1899,7 @@ def test_httpx_auto_instrumented_span_is_child_of_genai_span(self):
18161899
client=http_client,
18171900
server_url=server.url,
18181901
)
1902+
client.sdk_configuration.__dict__["telemetry"] = "global"
18191903
with tracer.start_as_current_span(
18201904
"ExecuteActivity:generate_site_diagnostic"
18211905
) as activity_span:
@@ -1858,6 +1942,7 @@ def raise_connect_error(request: httpx.Request) -> httpx.Response:
18581942
client=http_client,
18591943
server_url="https://api.mistral.ai",
18601944
)
1945+
client.sdk_configuration.__dict__["telemetry"] = "global"
18611946
with tracer.start_as_current_span(
18621947
"ExecuteActivity:generate_site_diagnostic"
18631948
) as activity_span:
@@ -1901,6 +1986,7 @@ async def _run(server_url: str):
19011986
async_client=async_client,
19021987
server_url=server_url,
19031988
)
1989+
client.sdk_configuration.__dict__["telemetry"] = "global"
19041990

19051991
with tracer.start_as_current_span(
19061992
"ExecuteActivity:generate_site_diagnostic"
@@ -1957,7 +2043,7 @@ def test_custom_provider_captures_spans(self):
19572043
hook = TracingHook()
19582044
hook.tracer_provider = custom_provider
19592045

1960-
hook_ctx = _make_hook_context("chat_completion")
2046+
hook_ctx = _make_hook_context("chat_completion", telemetry=None)
19612047

19622048
request_body = _dump(
19632049
ChatCompletionRequest(
@@ -2002,12 +2088,13 @@ def test_custom_provider_captures_spans(self):
20022088
]
20032089
self.assertEqual(len(global_spans), 0)
20042090

2005-
def test_fallback_to_global_provider(self):
2006-
"""When tracer_provider is None (default), spans go to the global provider."""
2091+
def test_global_telemetry_uses_global_provider(self):
2092+
"""When telemetry is set to global, spans go to the global provider."""
20072093
_EXPORTER.clear()
20082094

20092095
hook = TracingHook()
2010-
# tracer_provider defaults to None — should use global provider
2096+
# tracer_provider defaults to None; telemetry="global" opts into the
2097+
# configured global provider.
20112098
self.assertIsNone(hook.tracer_provider)
20122099

20132100
hook_ctx = _make_hook_context("chat_completion")

0 commit comments

Comments
 (0)