{"id":16697,"date":"2026-07-03T00:00:00","date_gmt":"2026-07-03T07:00:00","guid":{"rendered":"https:\/\/devblogs.microsoft.com\/ise\/?p=16697"},"modified":"2026-07-03T05:50:41","modified_gmt":"2026-07-03T12:50:41","slug":"mlflow-autolog-pyspark-workers","status":"publish","type":"post","link":"https:\/\/devblogs.microsoft.com\/ise\/mlflow-autolog-pyspark-workers\/","title":{"rendered":"Enabling MLflow OpenAI Autolog on PySpark Workers"},"content":{"rendered":"<h2>Context<\/h2>\n<p>In a recent engagement, the team built an LLM-based contract intelligence pipeline on Azure Databricks. The goal was to extract entitlements from a large corpus of inconsistently formatted service-contract PDFs \u2014 what is covered, on which equipment, and under which terms \u2014 so downstream systems can tell what is in scope and what is billable.<\/p>\n<p>Rules or template-based extraction were not a realistic option given the variability in layout and wording across contracts, which made an LLM a good fit: it can absorb that variability, reason about context across a document, and emit structured output in a single pass.<\/p>\n<p>To parallelize the extraction, the pipeline fans those per-document LLM calls out across Spark workers. Per-call visibility into token spend, latency, and prompt\/response quality becomes essential to keep cost and output quality from drifting unnoticed.<\/p>\n<p>The natural tool for that is <code>mlflow.openai.autolog()<\/code>. <strong>The catch:<\/strong> getting it to reliably emit traces in this setup takes more than the docs suggest. If you are running instrumented LLM calls across PySpark workers, the patterns below are what finally made tracing work end-to-end.<\/p>\n<h2>Problem<\/h2>\n<p>The pipeline distributes OpenAI API calls across Spark workers using <code>mapInPandas<\/code> to process documents in parallel, with <code>mlflow.openai.autolog()<\/code> enabled for tracing. Traces from the driver looked exactly as expected; the workers produced <strong>none<\/strong>. The <a href=\"https:\/\/docs.databricks.com\/aws\/en\/mlflow\/databricks-autologging#limitations\">Databricks autologging docs<\/a> flag that autologging must be called explicitly on workers, but following that guidance alone was not enough. Three separate issues had to be solved before traces appeared reliably.<\/p>\n<h2>Solution<\/h2>\n<h3>Issue 1: Workers Need Full MLflow Context<\/h3>\n<p>Calling <code>mlflow.openai.autolog()<\/code> on workers is necessary but not sufficient. Workers also need the MLflow tracking URI and experiment name &#8211; neither is inherited from the driver.<\/p>\n<pre><code class=\"language-python\"># Capture on the driver before mapInPandas\r\n_tracking_uri = mlflow.get_tracking_uri()\r\n_experiment_name = \"\/Shared\/my-experiment\"\r\n\r\ndef process_partition(batch_iter):\r\n    import mlflow\r\n\r\n    # All three are required on workers\r\n    mlflow.set_tracking_uri(_tracking_uri)\r\n    mlflow.set_experiment(_experiment_name)\r\n    mlflow.openai.autolog()\r\n\r\n    # ... LLM calls here<\/code><\/pre>\n<p>Without <code>set_tracking_uri<\/code> and <code>set_experiment<\/code>, autolog silently discards traces. The tracking URI defaults to an empty local path on workers, and without an experiment, traces have nowhere to go. On Unity Catalog-enabled workspaces, workers also need access to the experiment path \u2014 permissions don&#8217;t carry over from the driver. The failure mode is the same: a silent drop.<\/p>\n<p>Since Spark can reuse worker processes across partitions, a module-level flag avoids redundant setup:<\/p>\n<pre><code class=\"language-python\">_worker_initialized = False\r\n\r\ndef process_partition(batch_iter):\r\n    global _worker_initialized\r\n    if not _worker_initialized:\r\n        os.environ[\"MLFLOW_ENABLE_ASYNC_TRACE_LOGGING\"] = \"false\"\r\n        mlflow.set_tracking_uri(_tracking_uri)\r\n        mlflow.set_experiment(_experiment_name)\r\n        mlflow.openai.autolog()\r\n        _worker_initialized = True\r\n    # ... LLM calls<\/code><\/pre>\n<h3>Issue 2: Span Artifacts Lost Due to Async Export<\/h3>\n<p>After fixing the context propagation, traces appeared in the experiment list with metadata (inputs, outputs, token counts), but the &#8220;detailed trace view&#8221; in the MLflow UI was broken for most traces. Investigation revealed that 5 out of 6 trace span artifacts were missing from storage.<\/p>\n<p>The root cause: MLflow&#8217;s <code>AsyncTraceExportQueue<\/code> writes span artifacts via a background daemon thread, relying on <code>atexit<\/code> to flush on shutdown. When running as a <strong>Databricks job task<\/strong>, the Python process exits shortly after the pipeline completes. The daemon thread races against process termination, and <code>atexit<\/code> hooks may not complete in time.<\/p>\n<p>The fix is to disable async logging on workers:<\/p>\n<pre><code class=\"language-python\">import os\r\nos.environ[\"MLFLOW_ENABLE_ASYNC_TRACE_LOGGING\"] = \"false\"\r\nmlflow.openai.autolog()<\/code><\/pre>\n<p>This forces synchronous trace export. The overhead is approximately 100-500ms per trace, negligible compared to 5-20 second LLM call latency. Alternatively, this can be set as a <strong>cluster or job environment variable<\/strong> so it applies to all workers without code changes.<\/p>\n<h3>Issue 3: No Parent-Child Trace Linking<\/h3>\n<p>Each <code>chat.completions.create()<\/code> call produces an independent trace. The MLflow autolog implementation uses <code>start_span_no_context()<\/code> in <code>mlflow\/openai\/autolog.py<\/code>, which creates root spans without checking for a parent context. There is currently no mechanism to group worker traces under a single parent span.<\/p>\n<p>Processing 6 documents produces 6 disconnected traces. Correlation is possible by timestamp and experiment, but no trace hierarchy exists in the UI. A <a href=\"https:\/\/github.com\/mlflow\/mlflow\/issues\/21813\">feature request<\/a> has been filed with the MLflow team. Realistic workarounds include:<\/p>\n<ul>\n<li>Tagging traces with a shared <code>batch_id<\/code> via <code>mlflow.set_span_attribute()<\/code><\/li>\n<li>Dropping autolog and using <code>mlflow.start_trace()<\/code> manually for full hierarchy control, at the cost of losing autolog&#8217;s structured ChatCompletion parsing<\/li>\n<\/ul>\n<h3>Complete Pattern<\/h3>\n<pre><code class=\"language-python\">_tracking_uri = mlflow.get_tracking_uri()\r\n_experiment_name = \"\/Shared\/my-experiment\"\r\n\r\ndef process_partition(batch_iter):\r\n    import os, mlflow\r\n    os.environ[\"MLFLOW_ENABLE_ASYNC_TRACE_LOGGING\"] = \"false\"\r\n    mlflow.set_tracking_uri(_tracking_uri)\r\n    mlflow.set_experiment(_experiment_name)\r\n    mlflow.openai.autolog()\r\n\r\n    client = DatabricksOpenAI(workspace_client=WorkspaceClient(\r\n        host=_host, token=_token))\r\n    for batch_df in batch_iter:\r\n        for _, row in batch_df.iterrows():\r\n            client.chat.completions.create(\r\n                model=\"endpoint\", messages=[...])\r\n        yield batch_df\r\n\r\ninput_df.mapInPandas(process_partition, schema=schema).collect()<\/code><\/pre>\n<h2>Results<\/h2>\n<p>After applying the fixes, every LLM call across all pipeline stages produces a structured trace with model details, system\/user messages, full response, and token usage. Key learnings:<\/p>\n<ul>\n<li><strong>MLflow autolog on workers requires three things<\/strong>: tracking URI, experiment name, and the autolog call itself. Missing any one quietly produces zero traces.<\/li>\n<li><strong>Async trace export is unsafe in job tasks<\/strong>: the daemon thread flush races against process termination. Disable it with <code>MLFLOW_ENABLE_ASYNC_TRACE_LOGGING=false<\/code>.<\/li>\n<li><strong>Observability reveals hidden costs<\/strong>: once per-call tracing was working, the team discovered that some pipeline stages were re-executing LLM calls multiple times without warning due to Spark lazy evaluation. This cost multiplier had been invisible without autolog and was straightforward to fix once measured.<\/li>\n<\/ul>\n<h2>Conclusion<\/h2>\n<p>Enabling <code>mlflow.openai.autolog()<\/code> on PySpark workers is straightforward once the pitfalls are known, but discovering them requires reading MLflow internals. The silent failure modes (e.g., no errors, no warnings, just missing traces) make these issues particularly difficult to diagnose. Investing in per-call observability early paid off not only for tracing but also for uncovering hidden cost multipliers in the pipeline.<\/p>\n<h2>Attribution<\/h2>\n<p>Featured image created by copilot.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>When distributing LLM calls across PySpark workers via mapInPandas, MLflow autolog silently fails. Here is how to fix it.<\/p>\n","protected":false},"author":115111,"featured_media":16698,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"footnotes":""},"categories":[1,19],"tags":[3400],"class_list":["post-16697","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-cse","category-machine-learning","tag-ise"],"acf":[],"blog_post_summary":"<p>When distributing LLM calls across PySpark workers via mapInPandas, MLflow autolog silently fails. Here is how to fix it.<\/p>\n","_links":{"self":[{"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/posts\/16697","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/users\/115111"}],"replies":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/comments?post=16697"}],"version-history":[{"count":1,"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/posts\/16697\/revisions"}],"predecessor-version":[{"id":16699,"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/posts\/16697\/revisions\/16699"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/media\/16698"}],"wp:attachment":[{"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/media?parent=16697"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/categories?post=16697"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/devblogs.microsoft.com\/ise\/wp-json\/wp\/v2\/tags?post=16697"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}