Library telemetry in provrun runs
These notes list each path by which LiteLLM, PydanticAI, OpenTelemetry, Logfire, openai and anthropic can record or send data in a provrun run. For each path they say how it is switched on, how to suppress it and how to detect it. MLflow autolog is out of scope here. MLflow autolog covers it.
The notes cover the versions in the MahaCata workspace on 2026-11-01:
| Package | Version |
|---|---|
| pydantic-ai | 2.64.0 |
| litellm | 1.83.0 |
| logfire | 5.4.0 |
| opentelemetry-sdk and opentelemetry-api | 1.45.1 |
| opentelemetry-instrumentation and opentelemetry-instrumentation-httpx | 0.66b1 |
| openai | 3.41.0 |
| anthropic | 2.3.0 |
| mlflow, for one coexistence test | 3.17.2 |
Every citation is a path relative to site-packages in the workspace’s environment, with a line number. The MLflow citations are relative to the MLflow 3.17.2 source.
Terms
- Telemetry path: any mechanism that copies data about a run out of provrun’s control. The data can go to a remote endpoint, a terminal, a log file or a span processor that provrun did not add.
- Callback: a function or object that LiteLLM calls before or after each model call. LiteLLM passes it the messages and the response.
- Tracer provider: the OpenTelemetry object that makes tracers. Its span processors decide where each finished span goes.
- Global tracer provider: the one tracer provider that
opentelemetry.trace.get_tracer_provider()returns. A process can set it only once. - Span processor: an object on a tracer provider that receives each span. An exporter behind it sends the span somewhere.
- Instrumentor: an OpenTelemetry package that patches a library, for example httpx, so that the library makes spans.
- Net guard: the audit hook in the test scripts. It records and blocks every DNS lookup and connection to a host other than loopback.
How the tests were run
The test scripts were run in a scratch folder, and they are not in the repository. The notes name each one. No test called a real model or sent data to a remote endpoint.
- Net guard.
netguard.pyinstallssys.addaudithookfor thesocket.getaddrinfoandsocket.connectevents. It records each non-loopback attempt and raises an error, so the attempt fails. - Model calls. The tests use PydanticAI
TestModel, LiteLLMmock_responseand a local HTTP server,mockserver.py. The server answers OpenAI chat and Anthropic messages requests on 127.0.0.1 and records the request headers. - Spans. The tests use
InMemorySpanExporteron a privateTracerProvider. - Marker text. The prompts contain
SECRET-PROMPT, the answers containSECRET-ANSWER, and the tool results containSECRET-TOOL-RESULT. A test counts these markers in output, logs and span attributes.
Main findings
- LiteLLM fetches its cost map at import.
import litellmmakes an HTTPS request to raw.githubusercontent.com, unlessLITELLM_LOCAL_MODEL_COST_MAP=Trueis set. The net guard caught the lookup. - LiteLLM loads a .env file at import. It searches upward from its own install folder, so it finds a
.envfile in the project that holds the virtual environment. - A per-call
callbacks=argument to LiteLLM is permanent. LiteLLM copies it into the global callback lists, and the callback then sees every later call. - The LiteLLM “otel” callback takes the global tracer provider. If no SDK provider is set, it makes one, sets it as global and prints spans with prompts to stdout. If provrun’s provider is set, it writes prompt bodies into provrun’s provider.
- No PydanticAI instrumentation is on by default. It is on only through
Agent.instrument_all,agent.instrument, anInstrumentationcapability or anInstrumentedModel. include_content=Falsedoes not remove everything. It removes prompt, answer and tool argument text. It keeps tool names, tool descriptions and JSON schemas.- Instrumented httpx sends baggage to the model provider. With PydanticAI instrumentation and an httpx instrumentor, each request carried
traceparentand abaggageheader with the agent name and conversation ID. - Logfire takes the global providers.
logfire.configure()sets the global tracer, meter and logger providers and the global propagator. It also exports toOTEL_EXPORTER_OTLP_ENDPOINTwhensend_to_logfire=False. - MLflow can add a processor to provrun’s provider. With
MLFLOW_USE_DEFAULT_TRACER_PROVIDER=false, MLflow adds its own span processor to the global provider, even when provrun owns that provider. OTEL_SDK_DISABLED=truealso stops provrun’s own spans. A private SDK provider recorded 0 spans with it.- The anthropic SDK logs the request body at DEBUG. The openai SDK 3.41.0 does not. LiteLLM logs the body at DEBUG.
- The openai and anthropic SDKs send
X-Stainless-*headers. The headers give the OS, CPU, Python version and SDK version. Neither SDK sends data to any host other than the model API.
LiteLLM
The callback lists
LiteLLM keeps its callbacks in module globals. All of them are empty after import, as the test t1_litellm_import.py printed:
| Global | Citation | Purpose |
|---|---|---|
litellm.callbacks |
litellm/__init__.py:157 |
Callbacks for every event; strings name a built-in integration |
litellm.input_callback |
litellm/__init__.py:96 |
Called before the call |
litellm.success_callback |
litellm/__init__.py:97 |
Called after a success |
litellm.failure_callback |
litellm/__init__.py:98 |
Called after a failure |
litellm.service_callback |
litellm/__init__.py:99 |
Service health events, for example Prometheus |
litellm.audit_log_callbacks |
litellm/__init__.py:100 |
Proxy audit logs |
litellm._async_input_callback |
litellm/__init__.py:177 |
Async form of input callbacks |
litellm._async_success_callback |
litellm/__init__.py:182 |
Async form of success callbacks |
litellm._async_failure_callback |
litellm/__init__.py:187 |
Async form of failure callbacks |
litellm.callback_settings |
litellm/__init__.py:162 |
Settings for named callbacks |
litellm.cold_storage_custom_logger |
litellm/__init__.py:152 |
A named logger for cold storage |
The list _custom_logger_compatible_callbacks_literal at litellm/__init__.py:102 names 48 built-in integrations. Among them are “otel”, “logfire”, “mlflow”, “langfuse”, “langfuse_otel”, “langsmith”, “datadog”, “arize”, “braintrust”, “posthog”, “s3_v2” and “gcs_bucket”. Each string turns into a logger object in _init_custom_logger_compatible_class at litellm/litellm_core_utils/litellm_logging.py:3654.
Ways the callbacks get switched on
- Assignment or append. Any code in the process can assign or append to the globals above.
- The per-call
callbacks=argument.function_setupatlitellm/utils.py:786pops it and appends each entry toinput_callback,success_callback,failure_callbackand the two async lists. The testt2_litellm_calls.pypassed a callback on one call. The callback then saw the next call, which had nocallbacks=argument. - The per-call
success_callback=andfailure_callback=arguments.litellm/utils.py:899tolitellm/utils.py:923use them for that call only. - The per-call
logger_fn=argument.litellm/utils.py:943stores it in a module global,user_logger_fn. - Named integrations from environment variables. No environment variable adds a callback by itself. But a named integration reads its settings from the environment when it starts. For example, “logfire” needs
LOGFIRE_TOKEN(litellm/litellm_core_utils/litellm_logging.py:3994), and “otel” readsOTEL_EXPORTER,OTEL_ENDPOINT,OTEL_HEADERSand theOTEL_EXPORTER_OTLP_*variables (litellm/integrations/opentelemetry.py:112tolitellm/integrations/opentelemetry.py:117). - The proxy configuration file. A LiteLLM proxy
config.yamlsetslitellm_settings.callbacks. provrun does not run the proxy, so this path applies only if provrun calls the proxy code. - Other libraries.
mlflow.litellm.autolog()adds an MLflow callback. The page on MLflow autolog covers it.
The “otel” integration and the global tracer provider
The “otel” integration decides its provider in _get_or_create_provider at litellm/integrations/opentelemetry.py:248. If the global provider is an SDK TracerProvider, it uses that provider and adds no exporter. Otherwise it makes a new provider with its own exporter and sets it as global (litellm/integrations/opentelemetry.py:296 to litellm/integrations/opentelemetry.py:319). The exporter defaults to the console exporter.
The test t12_litellm_otel.py showed both cases:
- No provider set. LiteLLM set its own provider as global. The console exporter wrote two spans to stdout, and both held the prompt. provrun prints its JSON on stdout, so this output breaks that JSON.
- provrun’s provider set first. The spans
litellm_requestandraw_gen_ai_requestwent to provrun’s provider, with the prompt in their attributes.
provrun already sets USE_OTEL_LITELLM_REQUEST_SPAN=true, so these loggers make their own span and do not write onto provrun’s step span (packages/provrun/src/provrun/backends/litellm.py:94).
Network access by LiteLLM itself
- The cost map fetch at import.
litellm/__init__.py:457callsget_model_cost_map. That function does anhttpx.getof the URL inLITELLM_MODEL_COST_MAP_URL, which defaults to raw.githubusercontent.com (litellm/litellm_core_utils/get_model_cost_map.py:153). The testt1_litellm_import.pycaught the lookup of raw.githubusercontent.com port 443. WithLITELLM_LOCAL_MODEL_COST_MAP=True, the import made no lookup and the source was “local” (litellm/litellm_core_utils/get_model_cost_map.py:258). - The Anthropic beta headers fetch.
litellm/anthropic_beta_headers_manager.py:122fetches a configuration file the first time LiteLLM needs it.LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS=Truestops the fetch (litellm/anthropic_beta_headers_manager.py:142). A mock Anthropic call int2_litellm_calls.pymade no fetch, so I did not see when the fetch happens. - The blog posts fetch.
litellm/litellm_core_utils/get_blog_posts.py:66fetches an RSS feed. Only the proxy calls it. - Hugging Face tokenizers. The token counter can download a tokenizer for some models.
litellm.disable_hf_tokenizer_download = Truestops it (litellm/__init__.py:1315,litellm/utils.py:2174). The tiktoken files come with the package (litellm/litellm_core_utils/default_encoding.py:18). - The
telemetryflag.litellm.telemetry = Trueatlitellm/__init__.py:214is not read anywhere in the package. LiteLLM 1.83.0 sends no usage pings.
The .env file
litellm/__init__.py:18 calls dotenv.load_dotenv() when LITELLM_MODE is “DEV”, which is the default. If the main program is a script file, python-dotenv searches upward from the folder of the caller, which is the litellm package (dotenv/main.py:366 to dotenv/main.py:377). If the main program is interactive or python -c, it searches from the current folder.
The test used a virtual environment in dotproj/ with a .env file beside .venv. A script in another folder imported litellm, and the variable from that .env file appeared in os.environ. With LITELLM_MODE=PRODUCTION, it did not appear. The variables do not override variables that are set already.
Message redaction
litellm.turn_off_message_logging = True replaced the messages with “redacted-by-litellm” in both kwargs["messages"] and the standard logging object that a callback receives (t3_litellm_debuglog.py). It is off by default (litellm/__init__.py:194). The response text was still present in the standard logging object in the test output, so I did not confirm that the response is redacted.
How to clear and guard the callbacks
litellm.logging_callback_manager._reset_all_callbacks() at litellm/litellm_core_utils/logging_callback_manager.py:359 clears six lists. It does not clear _async_input_callback, service_callback or audit_log_callbacks, so provrun must clear those itself. After the reset, the callback from the earlier test saw no more calls.
To guard the lists, provrun can record every list before the call and compare after the call. Any entry that is not on the approved list is a failure.
PydanticAI
Ways instrumentation gets switched on
PydanticAI makes no spans unless one of these paths is on. A default agent made 0 spans, even with a global SDK provider set (t5_pydanticai.py).
Agent.instrument_all(settings_or_bool). It sets the class variableAgent._instrument_defaultfor every agent (pydantic_ai/agent/__init__.py:1330). The default isFalse(pydantic_ai/agent/__init__.py:585).agent.instrument = settings_or_bool. This property sets one agent. The 2.64.0 constructor has noinstrument=parameter.- The
Instrumentationcapability.Agent(capabilities=[Instrumentation(settings)])turns on spans for that agent (pydantic_ai/capabilities/instrumentation.py:113). - An
InstrumentedModel. A model wrapped inInstrumentedModelturns on spans, and its settings win over the agent settings (pydantic_ai/agent/__init__.py:1929topydantic_ai/agent/__init__.py:1942). logfire.instrument_pydantic_ai(). It callsAgent.instrument_allwith no argument, setsagent.instrumentfor an agent, or returns anInstrumentedModelfor a model (logfire/_internal/integrations/pydantic_ai.py:59tologfire/_internal/integrations/pydantic_ai.py:63).
Agent.instrument_all(False) and agent.instrument = False do not stop an explicit Instrumentation capability. The test made 4 spans in both cases.
Which tracer provider it uses
InstrumentationSettings() takes tracer_provider and meter_provider arguments (pydantic_ai/models/instrumented.py:102). Without them, it reads the global providers when the settings object is made (pydantic_ai/models/instrumented.py:167). Before any global provider is set, the global provider is a proxy, and the tracer follows the provider that is set later.
logfire.instrument_pydantic_ai() passes Logfire’s own providers explicitly (logfire/_internal/integrations/pydantic_ai.py:30). So its spans go to Logfire’s provider, even when provrun owns the global provider.
PydanticAI also records a metric, gen_ai.client.token.usage, through the meter provider.
Spans and attributes
The test agent used TestModel and one tool. It made 4 spans, all with the instrumentation scope “pydantic-ai”:
| Span | Attributes with content |
|---|---|
invoke_agent agent |
pydantic_ai.all_messages, final_result, gen_ai.system_instructions |
chat test (one per model request) |
gen_ai.input.messages, gen_ai.output.messages, gen_ai.system_instructions, gen_ai.tool.definitions, model_request_parameters |
execute_tool lookup |
gen_ai.tool.call.arguments, gen_ai.tool.call.result |
With include_content=True, which is the default, the spans held the prompt, the instructions and the tool result. With include_content=False, they held none of these markers. But the message attributes stayed, with roles and part types only. gen_ai.tool.definitions and model_request_parameters still held the tool description and the JSON schema (t5b.py).
include_binary_content defaults to True, so images and files go into the spans as data unless provrun turns it off.
Baggage
The Instrumentation capability puts gen_ai.agent.name, gen_ai.agent.call.id and the conversation ID into OpenTelemetry baggage (pydantic_ai/capabilities/instrumentation.py:311). Baggage is context that a propagator copies into outgoing request headers.
The test t9_sdks.py ran an agent with an OpenAIChatModel against the local server, under opentelemetry-instrument. The request carried a traceparent header and a baggage header. The baggage header was gen_ai.agent.name=SECRET-AGENT-NAME,gen_ai.agent.call.id=...,gen_ai.conversation.id=.... Without the httpx instrumentor, the request had neither header.
How to force it off or point it at a provider provrun owns
- Off. Call
Agent.instrument_all(False)and setagent.instrument = False. Then remove anyInstrumentationcapability, and unwrap anyInstrumentedModel. - Detect at run time. provrun’s evidence capability receives the run context in
for_run.ctx.traceris aNoOpTracerwhen the run has no instrumentation (pydantic_ai/agent/__init__.py:5277topydantic_ai/agent/__init__.py:5281). The testt13_detect_pa.pysawNoOpTracerfor a plain agent. It sawProxyTracerfor an agent withInstrumentation()and afterinstrument_all(True). - Approved. Add
Instrumentation(InstrumentationSettings(tracer_provider=provrun_tp, meter_provider=provrun_mp, include_content=..., include_binary_content=False)). The test sent all 4 spans to the private provider and none to the global provider.
OpenTelemetry
The global tracer provider
set_tracer_provider works once. A second call logs the warning “Overriding of current TracerProvider is not allowed” and changes nothing (opentelemetry/trace/__init__.py:544 to opentelemetry/trace/__init__.py:552). The test t6_logfire.py showed the warning when Logfire tried to set the provider after provrun.
get_tracer_provider loads a provider from OTEL_PYTHON_TRACER_PROVIDER if no provider is set and the variable is set (opentelemetry/trace/__init__.py:564 to opentelemetry/trace/__init__.py:573). With OTEL_PYTHON_TRACER_PROVIDER=sdk_tracer_provider, the global provider was an SDK TracerProvider before any code set it (t8_otel.py).
Anyone with a reference to a provider can add a span processor to it. MLflow did so to provrun’s provider (see the section on MLflow).
Environment variables that switch on export or instrumentation
| Variable | Read by | Effect |
|---|---|---|
OTEL_SDK_DISABLED=true |
Every SDK TracerProvider, MeterProvider and LoggerProvider (opentelemetry/sdk/trace/__init__.py:1278) |
All SDK providers make no-op spans, provrun’s private provider too |
OTEL_PYTHON_TRACER_PROVIDER |
opentelemetry/trace/__init__.py:569 |
Loads a global provider from an entry point |
OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER, OTEL_LOGS_EXPORTER |
The SDK configurator (opentelemetry/sdk/_configuration/__init__.py:81), Logfire |
Choose exporters; the configurator runs only through a distro |
OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_TRACES_ENDPOINT |
Logfire, MLflow, LiteLLM “otel”, the configurator | Add an OTLP exporter |
OTEL_EXPORTER_OTLP_HEADERS |
The OTLP exporter, LiteLLM “otel” | Headers for the exporter, often a credential |
OTEL_CONFIG_FILE |
The SDK configurator (opentelemetry/sdk/_configuration/__init__.py:660) |
A declarative configuration file |
OTEL_PYTHON_DISTRO, OTEL_PYTHON_CONFIGURATOR |
opentelemetry/instrumentation/auto_instrumentation/_load.py:49, :129 |
Choose the distro and configurator for auto-instrumentation |
OTEL_PYTHON_DISABLED_INSTRUMENTATIONS |
opentelemetry/instrumentation/auto_instrumentation/_load.py:71 |
Turns off named instrumentors |
OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG |
opentelemetry/sdk/trace/sampling.py:466 |
Change the sampler of any provider made without a sampler |
OTEL_RESOURCE_ATTRIBUTES, OTEL_SERVICE_NAME |
opentelemetry/sdk/resources/__init__.py:333 |
Add attributes to every span of a provider made with Resource.create |
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT |
opentelemetry/sdk/trace/__init__.py:40 |
Cut attribute values and counts |
PYTHONPATH with the auto-instrumentation folder |
Python startup | Runs sitecustomize.py from opentelemetry/instrumentation/auto_instrumentation/ |
opentelemetry-instrument and sitecustomize
The workspace has the opentelemetry-instrument command, because logfire-sdk[httpx] pulls in opentelemetry-instrumentation-httpx. The command puts the auto-instrumentation folder first on PYTHONPATH, so Python runs its sitecustomize.py. That file calls initialize(), which loads every installed instrumentor (opentelemetry/instrumentation/auto_instrumentation/__init__.py:83 to opentelemetry/instrumentation/auto_instrumentation/__init__.py:105).
The workspace has no distro, so no configurator runs. Under the command, httpx was instrumented, but the global provider stayed a proxy and OTEL_TRACES_EXPORTER=console printed nothing (t8_otel.py). If someone installs opentelemetry-distro, the command also sets a global provider with an OTLP exporter by default. OTEL_PYTHON_DISABLED_INSTRUMENTATIONS=httpx,httpx2 left httpx not instrumented.
How provrun can own the provider
- Set its provider first. Call
trace.set_tracer_provider(provrun_tp)before any library import that can set one. Then checktrace.get_tracer_provider() is provrun_tp. If the check fails, another component set the provider first. - Make the provider fixed. Pass an explicit sampler, an explicit
Resource(...)and explicitSpanLimits, so that theOTEL_*variables do not change provrun’s spans. - Refuse other processors. A subclass of
TracerProvidercan refuseadd_span_processorafter provrun has added its own. The testt14_guarded_provider.pyrefused MLflow’sMlflowV3SpanProcessorand recorded the attempt. - Use a private provider. provrun can keep its provider private and pass it explicitly to PydanticAI. But
opentelemetry/trace.get_tracer("provrun")inpackages/provrun/src/provrun/otel.pyreads the global provider, so that module must take the private provider too. A private provider does not stop other libraries from setting the global one. - Own the meter and logger providers too. Logfire sets all three globals, and PydanticAI records a metric.
- Remove
OTEL_SDK_DISABLEDfor provrun’s provider. The variable stops provrun’s own spans, so provrun should not use it as the off switch. If it is set, provrun should warn.
Logfire
What logfire.configure does
- Global providers. For the default instance,
configure()sets the global tracer, meter and logger providers once (logfire/_internal/config.py:1517tologfire/_internal/config.py:1521).configure(local=True)does not set them. The testt6_logfire.pyshowed both cases. - Global propagator. Both
local=Trueandlocal=Falseset the global text map propagator to a wrapper,WarnOnExtractTraceContextPropagator(logfire/_internal/config.py:1546tologfire/_internal/config.py:1555). - Executors. Both forms call
instrument_executors(), which patchesThreadPoolExecutorandProcessPoolExecutorto carry context (logfire/_internal/config.py:1544). - send_to_logfire. The default comes from
LOGFIRE_SEND_TO_LOGFIRE, then frompyproject.toml[tool.logfire]. Otherwise it isTrue, orFalseunder pytest (logfire/_internal/config_params.py:54). With the default and no token,configure()raisedLogfireConfigErrorand asked the user to runlogfire auth. Withif-token-present, it sent nothing. - Token.
LOGFIRE_TOKENgives the write token (logfire/_internal/config_params.py:62). A credentials file in.logfire/also gives one. The exporter sends spans to{base_url}/v1/traceswith the token in theAuthorizationheader (logfire/_internal/config.py:1333tologfire/_internal/config.py:1337). When a token is present, a thread also checks it against the Logfire API. - OTLP from the environment. If
OTEL_EXPORTER_OTLP_ENDPOINTorOTEL_EXPORTER_OTLP_TRACES_ENDPOINTis set, Logfire adds an OTLP exporter, whateversend_to_logfiresays (logfire/_internal/config.py:1427tologfire/_internal/config.py:1436). Withsend_to_logfire=Falseand the variable set to an example host, the net guard caught repeated lookups of that host. - Console. The console exporter is on by default (
logfire/_internal/config_params.py:78).console=FalseorLOGFIRE_CONSOLE=falseturns it off. - Additional processors.
additional_span_processors=[...]adds processors to Logfire’s provider. The test used it to capture Logfire’s spans in memory.
Other Logfire paths
- The Pydantic plugin. logfire-sdk registers a Pydantic plugin through an entry point. Pydantic loads it for every model. It records validations only if
LOGFIRE_PYDANTIC_PLUGIN_RECORDor[tool.logfire]sets the record mode, and the default is “off” (logfire/_internal/config_params.py:101).PYDANTIC_DISABLE_PLUGINS=logfire-pluginstops Pydantic from loading it. - The pytest plugin. logfire-sdk registers a pytest plugin,
logfire._internal.integrations.pytest. It runs only in test runs. logfire.instrument_httpx()and other instrument calls. They patch libraries in the same way as the OpenTelemetry instrumentors, and httpx then sends baggage headers.
Coexistence with MLflow tracing
MLFLOW_USE_DEFAULT_TRACER_PROVIDER defaults to True. Its name says the opposite of what it does. With True, MLflow uses an isolated provider of its own (mlflow/environment_variables.py:1055 to mlflow/environment_variables.py:1061). With False, MLflow uses the global provider and adds its processor to it.
The test t11_mlflow_provider.py set provrun’s provider first and then made an MLflow span with an OpenTelemetry child span:
| Setting | Processors on provrun’s provider after the span | Spans that provrun’s provider saw |
|---|---|---|
| Default | provrun’s only | The child span, as a root span |
MLFLOW_USE_DEFAULT_TRACER_PROVIDER=false |
provrun’s and MlflowV3SpanProcessor |
The MLflow span and its child |
MLFLOW_TRACE_PROPAGATE_TO_OTEL_CONTEXT=true |
provrun’s only | The child span, with the MLflow span as its parent |
So with false, MLflow exports every span of provrun’s provider, which includes PydanticAI and LiteLLM spans. Logfire and MLflow can both take the global provider. Logfire with local=True and MLflow in its default isolated mode do not compete for it.
An approved export to Logfire under provrun’s provider
There are two forms. I did not test either against the real Logfire service.
- No Logfire SDK at all. provrun adds
BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{base_url}/v1/traces", headers={"Authorization": token}))to its own provider. LiteLLM’s “logfire” integration and Logfire itself export in the same way. provrun keeps the provider and the processor list, and Logfire is one approved destination. - A local Logfire instance. provrun calls
logfire.configure(local=True, send_to_logfire=True, token=..., console=False)and passes that instance’s provider toInstrumentationSettings. This form leaves the global tracer provider alone. But it still changes the global propagator and patches the executors, and it still readsOTEL_EXPORTER_OTLP_*.
Form 1 gives provrun the most control. Form 2 adds Logfire’s scrubbing of secrets and its token check.
The openai and anthropic SDKs
Neither SDK sends data to a host other than the model API. The net guard saw no other lookups in t9_sdks.py.
Each request carries these headers, as the local server recorded:
- openai 3.41.0.
User-Agent: AsyncOpenAI/Python 3.41.0,X-Stainless-Lang,X-Stainless-Package-Version,X-Stainless-OS,X-Stainless-Arch,X-Stainless-Runtime,X-Stainless-Runtime-Version,X-Stainless-Async,X-Stainless-Retry-CountandX-Stainless-Read-Timeout(openai/_base_client.py:2305toopenai/_base_client.py:2312). - anthropic 2.3.0. The same
X-Stainless-*headers, plusX-Stainless-Timeoutandanthropic-version(anthropic/_base_client.py:2658toanthropic/_base_client.py:2665). - Through PydanticAI. The
User-Agentbecamepydantic-ai/2.64.0. - Through LiteLLM. LiteLLM used the openai SDK and added
X-Stainless-Raw-Response.
The headers tell the provider the OS, the CPU type, the Python version and the SDK version. default_headers on the client can override them. I did not test that.
Both SDKs use httpx2, a fork of httpx. The instrumentor package has a separate httpx2 entry point for it.
Python logging
| Library | Logger names | Body at DEBUG | Switched on by |
|---|---|---|---|
| LiteLLM | LiteLLM, LiteLLM Router, LiteLLM Proxy |
Yes, prompt and answer | Root logger at DEBUG, litellm._turn_on_debug() |
| anthropic | anthropic, anthropic._base_client |
Yes, the request options with the messages | ANTHROPIC_LOG=debug, root logger at DEBUG |
| openai | openai, openai._base_client |
No, only the method and status | OPENAI_LOG=debug |
| httpx2 and httpcore2 | httpx2, httpcore2.connection, httpcore2.http11 |
No, URL and status only | Root logger at DEBUG |
| PydanticAI | none for bodies | No | Not applicable |
Notes on each row:
- LiteLLM.
litellm/_logging.py:282tolitellm/_logging.py:289add a stderr handler at the level inLITELLM_LOG, which defaults to DEBUG. But the loggers themselves stay at NOTSET, so they follow the root logger.LITELLM_LOG=DEBUGalone printed nothing in the test. With the root logger at DEBUG, stderr held the prompt 6 times and the answer 2 times.litellm._turn_on_debug()sets the three loggers to DEBUG (litellm/_logging.py:423).litellm.set_verbose = Trueprinted nothing in the test. LiteLLM also sets thehttpxlogger to WARNING at import (litellm/_logging.py:293).JSON_LOGSswitches the format. - anthropic.
anthropic/_base_client.py:601logs “Request options” with the request JSON at DEBUG. The test found the prompt once on stderr. - openai. openai 3.41.0 logs no body (
openai/_base_client.py:606).OPENAI_LOGsets the level of theopenailogger (openai/_utils/_logs.py:38toopenai/_utils/_logs.py:47).
LiteLLM and anthropic have their own handlers, so they write to stderr even when provrun’s handlers do not take their records.
Suppression recipe
These steps are a proposal for provrun. Only the parts named in the test notes were tested.
- Before any library import, set
LITELLM_LOCAL_MODEL_COST_MAP=True,LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS=True,LITELLM_MODE=PRODUCTIONandPYDANTIC_AI_NO_BANNER=1. Do not setOTEL_SDK_DISABLED. - Before any library import, remove from the environment every
OTEL_EXPORTER_OTLP_*,OTEL_TRACES_EXPORTER,OTEL_PYTHON_TRACER_PROVIDER,OTEL_CONFIG_FILE,LOGFIRE_*,LITELLM_LOGandANTHROPIC_LOGvariable that is not approved. Fail if the auto-instrumentationsitecustomizemodule is loaded. - Set provrun’s providers. Make a guarded tracer provider, a meter provider and a logger provider with explicit settings. Set all three as global, and set a propagator that injects nothing.
- Configure PydanticAI. Call
Agent.instrument_all(False). If the configuration approves instrumentation, addInstrumentationwith provrun’s providers,include_binary_content=Falseand the approvedinclude_content. - Configure LiteLLM. Clear every callback list in the LiteLLM table, then add the approved callbacks. Set
turn_off_message_loggingfrom the configuration anddisable_hf_tokenizer_download = True. - Configure logging. Set each logger in the logging table to WARNING or higher. Root DEBUG then cannot turn them on.
- Install a net guard. Add an audit hook that lets provrun connect only to the model host and the approved exporter hosts. A blocked connection fails the run and goes into the evidence.
Detection during a run
Before and after each model call and at the end of the run, provrun can check these things:
- LiteLLM callbacks. Compare every callback global in the LiteLLM table with the snapshot. Check
turn_off_message_loggingand the level of theLiteLLMlogger. - PydanticAI. Check
Agent._instrument_default, and checkctx.tracerin the evidence capability. Any tracer that is not aNoOpTracermeans instrumentation is on, and it must be the approved one. - The global providers. Check that
trace.get_tracer_provider(),metrics.get_meter_provider()and_logs.get_logger_provider()are provrun’s objects. Check the propagator too. - Processors. Read the refusals that the guarded provider recorded.
- Instrumentors. Check
HTTPXClientInstrumentor().is_instrumented_by_opentelemetryand the same forhttpx2. - Logfire. Check
logfire.DEFAULT_LOGFIRE_INSTANCE.config._initialized. - Network. Read the events that the net guard recorded.
Summary table
| Path | On by default | How it is switched on | How to suppress | How to detect |
|---|---|---|---|---|
| LiteLLM cost map fetch | Yes, at import | Always, unless the variable is set | LITELLM_LOCAL_MODEL_COST_MAP=True |
Net guard; get_model_cost_map_source_info() |
| LiteLLM beta headers fetch | Yes, on first use | Always, unless the variable is set | LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS=True |
Net guard |
| LiteLLM tokenizer download | Yes, for some models | Token counting | litellm.disable_hf_tokenizer_download = True |
Net guard |
| LiteLLM .env load | Yes, at import | LITELLM_MODE=DEV, the default |
LITELLM_MODE=PRODUCTION |
Compare os.environ before and after import |
| LiteLLM callback lists | No | Assignment, append, per-call callbacks= |
Clear every callback list | Snapshot and compare |
| LiteLLM “otel” integration | No | "otel" in a callback list |
Clear the lists; guarded provider | Global provider check; callback snapshot |
| LiteLLM “logfire” and other named integrations | No | Name in a callback list, settings from the environment | Clear the lists; net guard | Callback snapshot; net guard |
| LiteLLM debug logs with bodies | No | Root logger at DEBUG; _turn_on_debug() |
Set its loggers to WARNING | Logger level check |
| PydanticAI instrumentation | No | instrument_all, agent.instrument, Instrumentation, InstrumentedModel, logfire.instrument_pydantic_ai |
instrument_all(False); remove the capability |
ctx.tracer is not a NoOpTracer |
| PydanticAI baggage in requests | No | Instrumentation and an httpx instrumentor | Propagator that injects nothing; no instrumentor | Instrumentor check; local server headers |
| OTel auto-instrumentation | No | opentelemetry-instrument; PYTHONPATH |
Refuse to run; OTEL_PYTHON_DISABLED_INSTRUMENTATIONS |
sitecustomize module path; instrumentor check |
| OTel global provider from the environment | No | OTEL_PYTHON_TRACER_PROVIDER |
Remove the variable; set provrun’s provider first | Global provider check |
| OTel SDK switch-off | No | OTEL_SDK_DISABLED=true |
Remove the variable | Read the variable; provrun’s span count is 0 |
| Logfire global providers | No | logfire.configure() |
Do not call it, or use local=True |
Global provider check |
| Logfire export to Logfire | Yes, once configured with a token | LOGFIRE_TOKEN, a credentials file |
send_to_logfire=False; remove the token |
Net guard; Logfire config check |
| Logfire OTLP export | No | OTEL_EXPORTER_OTLP_ENDPOINT and a configure() call |
Remove the variable | Net guard; processor list of Logfire’s provider |
| Logfire console output | Yes, once configured | configure() |
console=False |
stdout check |
| Logfire Pydantic plugin | Loaded, records nothing | LOGFIRE_PYDANTIC_PLUGIN_RECORD |
PYDANTIC_DISABLE_PLUGINS=logfire-plugin |
Read the variable |
| MLflow processor on provrun’s provider | No | MLFLOW_USE_DEFAULT_TRACER_PROVIDER=false |
Guarded provider; keep the default | Guarded provider refusals |
openai and anthropic X-Stainless-* headers |
Yes | Always | default_headers on the client, untested |
Local server |
| anthropic request body log | No | ANTHROPIC_LOG=debug; root logger at DEBUG |
Set its logger to WARNING | Logger level check |
| openai debug log | No | OPENAI_LOG=debug |
Set its logger to WARNING | Logger level check |
Open questions
- The beta headers fetch. I did not find which LiteLLM call path first loads the Anthropic beta headers configuration. A mock call did not load it.
- Response redaction in LiteLLM. With
turn_off_message_logging, the test showed redacted messages. The response text was still present in the standard logging object in the test output. I did not check further. - Overriding the
X-Stainless-*headers. I did not test whetherdefault_headerscan remove the headers, or only change their values. - MLflow with the guarded provider. When the guarded provider refused MLflow’s processor, MLflow’s span also did not reach provrun’s exporter. I did not find the cause.
- Exports to the real Logfire service. Neither form of the approved Logfire path was tested against the real service, because the task forbids it.