diff --git a/README.md b/README.md index bf678597..e6576d75 100644 --- a/README.md +++ b/README.md @@ -1465,6 +1465,8 @@ with Mistral(api_key=os.environ["MISTRAL_API_KEY"]) as client: ) ``` +In dedicated mode, traces go to `/telemetry/v1/traces` on the client's `server_url` if one is set, otherwise to `https://api.mistral.ai`, including for the named `eu`/`us` servers. Set `MISTRAL_OTLP_TRACES_ENDPOINT` to send traces to a separate collector. + ### Provider modes `configure_telemetry(client, provider=...)` selects where spans go and who owns the export pipeline: @@ -1552,7 +1554,7 @@ configure_telemetry( | Variable | Description | Default | | -------- | ----------- | ------- | | `MISTRAL_SDK_TELEMETRY` | Auto-enable telemetry: `dedicated`, `global`, or `false`. | unset (disabled) | -| `MISTRAL_OTLP_TRACES_ENDPOINT` | Override the OTLP traces endpoint used in dedicated mode. | `https://api.mistral.ai/telemetry/v1/traces` | +| `MISTRAL_OTLP_TRACES_ENDPOINT` | Override the OTLP traces endpoint used in dedicated mode. | Client `server_url` + `/telemetry/v1/traces` if set, otherwise `https://api.mistral.ai/telemetry/v1/traces` | | `MISTRAL_SDK_DEBUG_TRACING` | Set to `true` for verbose tracing logs. | `false` | | `MISTRAL_API_KEY` | Used as the bearer token for the dedicated-mode exporter. | — | diff --git a/src/mistralai/extra/observability/telemetry.py b/src/mistralai/extra/observability/telemetry.py index a568d546..159c0ffe 100644 --- a/src/mistralai/extra/observability/telemetry.py +++ b/src/mistralai/extra/observability/telemetry.py @@ -9,6 +9,7 @@ from opentelemetry import trace as otel_trace +from mistralai.client.sdkconfiguration import SERVER_GLOBAL, SERVERS from mistralai.client.utils import get_security_from_env from .otel import MISTRAL_SDK_OTEL_TRACER_NAME, OTEL_SERVICE_NAME @@ -27,7 +28,8 @@ MISTRAL_SDK_TELEMETRY_ENV = "MISTRAL_SDK_TELEMETRY" -MISTRAL_TELEMETRY_ENDPOINT = "https://api.mistral.ai/telemetry/v1/traces" +MISTRAL_TELEMETRY_PATH = "/telemetry/v1/traces" +MISTRAL_TELEMETRY_ENDPOINT = f"{SERVERS[SERVER_GLOBAL]}{MISTRAL_TELEMETRY_PATH}" MISTRAL_OTLP_TRACES_ENDPOINT_ENV = "MISTRAL_OTLP_TRACES_ENDPOINT" TELEMETRY_PROVIDER_DEDICATED: Final[Literal["dedicated"]] = "dedicated" TELEMETRY_PROVIDER_GLOBAL: Final[Literal["global"]] = "global" @@ -114,7 +116,10 @@ def configure_telemetry( ) -> bool: """Configure telemetry provider mode for a Mistral client. - By default, this creates an SDK-owned telemetry provider/exporter. Passing + By default, this creates an SDK-owned telemetry provider/exporter that sends + traces to /telemetry/v1/traces on the client's explicit server_url if set, + otherwise to https://api.mistral.ai (also for the named eu/us servers). + MISTRAL_OTLP_TRACES_ENDPOINT overrides that endpoint. Passing provider="global" clears the per-client provider so SDK spans use the global OpenTelemetry provider. Passing a TracerProvider attaches it to this client without taking ownership of its lifecycle. @@ -272,6 +277,7 @@ def configure_telemetry_for_hook( api_key = _resolve_api_key_from_security(getattr(sdk_config, "security", None)) provider = _create_telemetry_tracer_provider( api_key=api_key, + server_url=getattr(sdk_config, "server_url", None), redaction=redaction, ) _attach_telemetry_provider(hook, provider, finalizer_owner or sdk_config) @@ -313,6 +319,7 @@ def _resolve_api_key_from_security(security: Any) -> str: def _create_telemetry_tracer_provider( *, api_key: str | None, + server_url: str | None = None, redaction: RedactionPolicyLike | bool = True, ) -> "SDKTracerProvider": ( @@ -328,7 +335,11 @@ def _create_telemetry_tracer_provider( "client or set MISTRAL_API_KEY." ) exporter = otlp_span_exporter_cls( - endpoint=_resolve_mistral_telemetry_endpoint(), + # Only follow an explicit server_url (e.g. another deployment); the named + # regional servers don't serve the telemetry endpoint. + endpoint=_resolve_mistral_telemetry_endpoint( + server_url or SERVERS[SERVER_GLOBAL] + ), headers={"Authorization": _as_bearer_token(api_key)}, ) policy = _resolve_redaction(redaction) @@ -359,11 +370,9 @@ def _load_otel_sdk(): return BatchSpanProcessor, OTLPSpanExporter, Resource, TracerProvider -def _resolve_mistral_telemetry_endpoint() -> str: - return os.getenv( - MISTRAL_OTLP_TRACES_ENDPOINT_ENV, - MISTRAL_TELEMETRY_ENDPOINT, - ).strip() or MISTRAL_TELEMETRY_ENDPOINT +def _resolve_mistral_telemetry_endpoint(server_url: str) -> str: + override = os.getenv(MISTRAL_OTLP_TRACES_ENDPOINT_ENV, "").strip() + return override or f"{server_url.rstrip('/')}{MISTRAL_TELEMETRY_PATH}" def _has_real_global_tracer_provider() -> bool: diff --git a/src/mistralai/extra/tests/test_telemetry.py b/src/mistralai/extra/tests/test_telemetry.py index a0f2fdad..1f86ba78 100644 --- a/src/mistralai/extra/tests/test_telemetry.py +++ b/src/mistralai/extra/tests/test_telemetry.py @@ -3,8 +3,10 @@ from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock, patch +import httpx2 as httpx import pytest from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SpanExportResult from mistralai.client._hooks import SDKHooks from mistralai.client._hooks.tracing import TracingHook @@ -151,6 +153,7 @@ def test_env_dedicated_values_attach_provider(self): self.assertTrue(configured) create_provider.assert_called_once_with( api_key="test-key", + server_url="", redaction=True, ) self.assertIs(_get_tracing_hook(client).tracer_provider, provider) @@ -215,6 +218,7 @@ def test_configure_telemetry_attaches_per_client_provider(self): self.assertTrue(configured) create_provider.assert_called_once_with( api_key="test-key", + server_url="", redaction=True, ) self.assertIs(_get_tracing_hook(client).tracer_provider, provider) @@ -233,6 +237,7 @@ def test_configure_telemetry_accepts_explicit_dedicated_provider_mode(self): self.assertTrue(configured) create_provider.assert_called_once_with( api_key="test-key", + server_url="", redaction=True, ) self.assertIs(_get_tracing_hook(client).tracer_provider, provider) @@ -316,7 +321,9 @@ def test_configure_telemetry_dedicated_reconfigure_applies_new_redaction(self): hook = _get_tracing_hook(client) self.assertTrue(configured) self.assertEqual(create_provider.call_count, 2) - create_provider.assert_any_call(api_key="test-key", redaction=False) + create_provider.assert_any_call( + api_key="test-key", server_url="", redaction=False + ) self.assertTrue(first_provider.shutdown_called) self.assertIs(hook.tracer_provider, second_provider) self.assertIs(hook._auto_telemetry_provider, second_provider) @@ -462,6 +469,7 @@ def test_env_dedicated_uses_mistral_api_key_fallback(self): self.assertTrue(configured) create_provider.assert_called_once_with( api_key="env-key", + server_url="", redaction=True, ) self.assertIs(_get_tracing_hook(client).tracer_provider, provider) @@ -502,6 +510,7 @@ def test_env_dedicated_ignores_standard_otel_endpoint_env(self): self.assertTrue(configured) create_provider.assert_called_once_with( api_key="test-key", + server_url="", redaction=True, ) @@ -679,6 +688,142 @@ def test_mistral_endpoint_env_overrides_default_endpoint(self): ) +class TestTelemetryEndpoint(unittest.TestCase): + @staticmethod + def _export_for_client( + client_options: dict[str, str], + auto_enable: bool, + endpoint_override: str | None = None, + ) -> tuple[dict, str]: + from mistralai.client.sdk import Mistral + + env = {MISTRAL_SDK_TELEMETRY_ENV: "dedicated"} if auto_enable else {} + if endpoint_override is not None: + env[MISTRAL_OTLP_TRACES_ENDPOINT_ENV] = endpoint_override + request_urls: list[str] = [] + + def handle_request(request: httpx.Request) -> httpx.Response: + request_urls.append(str(request.url)) + return httpx.Response(200, json={"object": "list", "data": []}) + + with ( + patch.dict(os.environ, env, clear=True), + patch( + "mistralai.extra.observability.telemetry._has_real_global_tracer_provider", + return_value=False, + ), + patch( + "opentelemetry.exporter.otlp.proto.http.trace_exporter.OTLPSpanExporter" + ) as exporter_class, + httpx.Client(transport=httpx.MockTransport(handle_request)) as http_client, + Mistral( + api_key="test-key", + client=http_client, + server=client_options.get("server"), + server_url=client_options.get("server_url"), + ) as client, + ): + exporter_class.return_value.export.return_value = SpanExportResult.SUCCESS + if not auto_enable: + configure_telemetry(client) + try: + client.models.list() + provider = _get_tracing_hook(client)._auto_telemetry_provider + assert provider is not None + assert provider.force_flush() + exporter_class.return_value.export.assert_called_once() + finally: + configure_telemetry(client, provider="global", redaction=False) + + exporter_class.assert_called_once() + assert len(request_urls) == 1 + return dict(exporter_class.call_args.kwargs), request_urls[0] + + def test_telemetry_follows_explicit_server_url(self): + # (client options, expected API base URL, expected telemetry base URL) + cases: list[tuple[dict[str, str], str, str]] = [ + ({}, "https://api.mistral.ai", "https://api.mistral.ai"), + ({"server": "global"}, "https://api.mistral.ai", "https://api.mistral.ai"), + # Named regional servers don't serve the telemetry endpoint. + ({"server": "eu"}, "https://api.eu.mistral.ai", "https://api.mistral.ai"), + ({"server": "us"}, "https://api.us.mistral.ai", "https://api.mistral.ai"), + ( + {"server_url": "https://mistral.example.com"}, + "https://mistral.example.com", + "https://mistral.example.com", + ), + ( + {"server_url": "https://llm.acme-corp.io/"}, + "https://llm.acme-corp.io", + "https://llm.acme-corp.io", + ), + ( + {"server_url": "https://gateway.internal.net/mistral/"}, + "https://gateway.internal.net/mistral", + "https://gateway.internal.net/mistral", + ), + ( + {"server_url": "http://localhost:8080"}, + "http://localhost:8080", + "http://localhost:8080", + ), + ( + {"server": "eu", "server_url": "https://mistral.contoso.dev"}, + "https://mistral.contoso.dev", + "https://mistral.contoso.dev", + ), + ] + for client_options, api_base_url, telemetry_base_url in cases: + for auto_enable in (False, True): + with self.subTest( + client_options=client_options, auto_enable=auto_enable + ): + exporter_options, request_url = self._export_for_client( + client_options, auto_enable + ) + self.assertEqual(request_url, f"{api_base_url}/v1/models") + self.assertEqual( + exporter_options, + { + "endpoint": f"{telemetry_base_url}/telemetry/v1/traces", + "headers": {"Authorization": "Bearer test-key"}, + }, + ) + + def test_blank_endpoint_override_uses_api_server(self): + for endpoint_override in (None, "", " "): + for auto_enable in (False, True): + with self.subTest( + endpoint_override=endpoint_override, auto_enable=auto_enable + ): + exporter_options, _ = self._export_for_client( + {"server_url": "https://inference.foobar.ai"}, + auto_enable, + endpoint_override, + ) + self.assertEqual( + exporter_options["endpoint"], + "https://inference.foobar.ai/telemetry/v1/traces", + ) + + def test_endpoint_override_takes_precedence(self): + for auto_enable in (False, True): + with self.subTest(auto_enable=auto_enable): + exporter_options, request_url = self._export_for_client( + {"server_url": "https://proxy.widgets.org"}, + auto_enable, + " https://collector.example.com/v1/traces ", + ) + self.assertEqual(request_url, "https://proxy.widgets.org/v1/models") + self.assertEqual( + exporter_options, + { + "endpoint": "https://collector.example.com/v1/traces", + "headers": {"Authorization": "Bearer test-key"}, + }, + ) + + _TELEMETRY_LOGGER = "mistralai.extra.observability.telemetry" @@ -751,7 +896,9 @@ def test_dedicated_mode_forwards_custom_redaction_to_provider(self): policy = RegexRedactionPolicy() configure_telemetry(client, provider="dedicated", redaction=policy) - create_provider.assert_called_once_with(api_key="test-key", redaction=policy) + create_provider.assert_called_once_with( + api_key="test-key", server_url="", redaction=policy + ) def test_dedicated_mode_forwards_default_redaction_to_provider(self): # Wiring test @@ -762,7 +909,9 @@ def test_dedicated_mode_forwards_default_redaction_to_provider(self): client = _make_client(api_key="test-key") configure_telemetry(client, provider="dedicated") - create_provider.assert_called_once_with(api_key="test-key", redaction=True) + create_provider.assert_called_once_with( + api_key="test-key", server_url="", redaction=True + ) def test_global_mode_warns_by_default(self, caplog): client = _make_client(api_key="test-key")