{"componentChunkName":"component---src-templates-blog-post-js","path":"/blog/2026-09-23-fastapi-correlation-id-logging/","result":{"data":{"site":{"siteMetadata":{"title":"M.Hassan Ahmed","author":"Hassan11196"}},"markdownRemark":{"id":"3da62bf6-c69a-5c02-a50f-641022efb820","excerpt":"An operator pings me: “the request I sent around 14:05 came back with a 500, can you check?” I open the logs, and there are forty other requests in that same…","html":"<p>An operator pings me: “the request I sent around 14:05 came back with a 500, can you check?” I open the logs, and there are forty other requests in that same second. My service logged a handler entry, three database calls, an outbound HTTP call to a model endpoint, and a stack trace, all interleaved with everyone else’s forty requests doing the same. Which log lines belong to <em>their</em> request? Without something tying them together, I’m reading tea leaves.</p>\n<p>The fix is old, boring, and works. Give every incoming request a unique ID, attach it to every log line that request produces, and return it in the response so the caller can quote it back to you. This is a correlation ID (some shops call it a request ID or trace ID).</p>\n<p>This post is for engineers running a <a href=\"https://fastapi.tiangolo.com/\">FastAPI</a> backend who want to pull one request’s story out of a busy log stream. I’ll show the middleware, the part that makes it work under async, the specific ways it breaks, and how it lands in <a href=\"https://opensearch.org/\">OpenSearch</a>, where I actually read these logs.</p>\n<p>The running example is the kind of backend I worked on for <a href=\"/project/archi/\">Archi</a>, the retrieval copilot for CMS computing operations at CERN, and the operator console in <a href=\"/project/cms-workflow-operations/\">CMS workflow operations</a>. Both are FastAPI services whose logs end up in OpenSearch, and both have had the “which lines are mine” problem.</p>\n<h2>Why the naive version doesn’t survive async</h2>\n<p>The instinct is to generate an ID at the top of the handler and pass it down as an argument:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token decorator annotation punctuation\">@app<span class=\"token punctuation\">.</span>post</span><span class=\"token punctuation\">(</span><span class=\"token string\">\"/answer\"</span><span class=\"token punctuation\">)</span>\n<span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">answer</span><span class=\"token punctuation\">(</span>req<span class=\"token punctuation\">:</span> Query<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    request_id <span class=\"token operator\">=</span> uuid<span class=\"token punctuation\">.</span>uuid4<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token builtin\">hex</span>\n    log<span class=\"token punctuation\">.</span>info<span class=\"token punctuation\">(</span><span class=\"token string\">\"received query\"</span><span class=\"token punctuation\">,</span> extra<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token string\">\"request_id\"</span><span class=\"token punctuation\">:</span> request_id<span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span>\n    docs <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> retrieve<span class=\"token punctuation\">(</span>req<span class=\"token punctuation\">.</span>text<span class=\"token punctuation\">,</span> request_id<span class=\"token punctuation\">)</span>   <span class=\"token comment\"># thread it through</span>\n    reply <span class=\"token operator\">=</span> <span class=\"token keyword\">await</span> generate<span class=\"token punctuation\">(</span>docs<span class=\"token punctuation\">,</span> request_id<span class=\"token punctuation\">)</span>       <span class=\"token comment\"># ...and again</span>\n    <span class=\"token keyword\">return</span> <span class=\"token punctuation\">{</span><span class=\"token string\">\"answer\"</span><span class=\"token punctuation\">:</span> reply<span class=\"token punctuation\">}</span></code></pre></div>\n<p>This works for exactly one function. The moment <code class=\"language-text\">retrieve</code> calls a repository, which calls an HTTP client, which logs a retry, you are either plumbing <code class=\"language-text\">request_id</code> through every function signature in the codebase or you have lost it. Threading a logging concern through your business logic looks fine in a demo and rots in a real service.</p>\n<p>You might reach for a global variable instead. That is worse, and the reason is specific to how FastAPI runs. FastAPI is an <a href=\"https://asgi.readthedocs.io/en/latest/\">ASGI</a> app (ASGI is the Asynchronous Server Gateway Interface) served on an event loop, so many requests are in flight on the <em>same</em> thread, each suspended at its own <code class=\"language-text\">await</code> point. A plain global holds one value for the whole process, so request B overwrites request A’s ID while A is parked waiting on a database. <code class=\"language-text\">threading.local</code> doesn’t save you either, because all these requests share a thread. You need storage scoped to a single logical task: not to a thread, and not to the process.</p>\n<h2>contextvars: storage scoped to one request</h2>\n<p>Python’s answer is <a href=\"https://docs.python.org/3/library/contextvars.html\"><code class=\"language-text\">contextvars</code></a>, added in 3.7 for exactly this problem. A <code class=\"language-text\">ContextVar</code> holds a value that is local to the current <em>context</em>, and asyncio gives every task its own copy of the context. If you set it inside one request’s task, a concurrent request’s task still sees its own value, not yours. The standard library uses the same mechanism to keep <code class=\"language-text\">Decimal</code> precision settings from leaking between tasks.</p>\n<p>Declaring one takes a single line:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token comment\"># context.py</span>\n<span class=\"token keyword\">from</span> contextvars <span class=\"token keyword\">import</span> ContextVar\n\nrequest_id_var<span class=\"token punctuation\">:</span> ContextVar<span class=\"token punctuation\">[</span><span class=\"token builtin\">str</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> ContextVar<span class=\"token punctuation\">(</span><span class=\"token string\">\"request_id\"</span><span class=\"token punctuation\">,</span> default<span class=\"token operator\">=</span><span class=\"token string\">\"-\"</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>The <code class=\"language-text\">default=&quot;-&quot;</code> matters. Code that logs outside any request (startup, a background job) still has <em>something</em> to write, so your log formatter never blows up on a missing value.</p>\n<h2>Setting the ID in middleware</h2>\n<p>Set the ID once, at the edge, before anything else runs. In Starlette (which FastAPI is built on), that means <a href=\"https://www.starlette.io/middleware/\">middleware</a>, a layer that wraps every request. I write it as pure ASGI rather than <code class=\"language-text\">BaseHTTPMiddleware</code>. That is partly habit, and partly because <code class=\"language-text\">BaseHTTPMiddleware</code> has <a href=\"https://github.com/encode/starlette/issues/1678\">a long history</a> of surprising interactions with background tasks and streaming responses.</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token comment\"># middleware.py</span>\n<span class=\"token keyword\">import</span> uuid\n<span class=\"token keyword\">from</span> starlette<span class=\"token punctuation\">.</span>types <span class=\"token keyword\">import</span> ASGIApp<span class=\"token punctuation\">,</span> Receive<span class=\"token punctuation\">,</span> Scope<span class=\"token punctuation\">,</span> Send\n<span class=\"token keyword\">from</span> context <span class=\"token keyword\">import</span> request_id_var\n\n<span class=\"token keyword\">class</span> <span class=\"token class-name\">CorrelationIdMiddleware</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">def</span> <span class=\"token function\">__init__</span><span class=\"token punctuation\">(</span>self<span class=\"token punctuation\">,</span> app<span class=\"token punctuation\">:</span> ASGIApp<span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token boolean\">None</span><span class=\"token punctuation\">:</span>\n        self<span class=\"token punctuation\">.</span>app <span class=\"token operator\">=</span> app\n\n    <span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">__call__</span><span class=\"token punctuation\">(</span>self<span class=\"token punctuation\">,</span> scope<span class=\"token punctuation\">:</span> Scope<span class=\"token punctuation\">,</span> receive<span class=\"token punctuation\">:</span> Receive<span class=\"token punctuation\">,</span> send<span class=\"token punctuation\">:</span> Send<span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token boolean\">None</span><span class=\"token punctuation\">:</span>\n        <span class=\"token keyword\">if</span> scope<span class=\"token punctuation\">[</span><span class=\"token string\">\"type\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">!=</span> <span class=\"token string\">\"http\"</span><span class=\"token punctuation\">:</span>\n            <span class=\"token keyword\">await</span> self<span class=\"token punctuation\">.</span>app<span class=\"token punctuation\">(</span>scope<span class=\"token punctuation\">,</span> receive<span class=\"token punctuation\">,</span> send<span class=\"token punctuation\">)</span>\n            <span class=\"token keyword\">return</span>\n\n        headers <span class=\"token operator\">=</span> <span class=\"token builtin\">dict</span><span class=\"token punctuation\">(</span>scope<span class=\"token punctuation\">[</span><span class=\"token string\">\"headers\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">)</span>  <span class=\"token comment\"># bytes keys and values</span>\n        incoming <span class=\"token operator\">=</span> headers<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span><span class=\"token string\">b\"x-request-id\"</span><span class=\"token punctuation\">)</span>\n        request_id <span class=\"token operator\">=</span> incoming<span class=\"token punctuation\">.</span>decode<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token keyword\">if</span> incoming <span class=\"token keyword\">else</span> uuid<span class=\"token punctuation\">.</span>uuid4<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">.</span><span class=\"token builtin\">hex</span>\n        token <span class=\"token operator\">=</span> request_id_var<span class=\"token punctuation\">.</span><span class=\"token builtin\">set</span><span class=\"token punctuation\">(</span>request_id<span class=\"token punctuation\">)</span>\n\n        <span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">send_with_header</span><span class=\"token punctuation\">(</span>message<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n            <span class=\"token keyword\">if</span> message<span class=\"token punctuation\">[</span><span class=\"token string\">\"type\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">==</span> <span class=\"token string\">\"http.response.start\"</span><span class=\"token punctuation\">:</span>\n                message<span class=\"token punctuation\">[</span><span class=\"token string\">\"headers\"</span><span class=\"token punctuation\">]</span><span class=\"token punctuation\">.</span>append<span class=\"token punctuation\">(</span>\n                    <span class=\"token punctuation\">(</span><span class=\"token string\">b\"x-request-id\"</span><span class=\"token punctuation\">,</span> request_id<span class=\"token punctuation\">.</span>encode<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n                <span class=\"token punctuation\">)</span>\n            <span class=\"token keyword\">await</span> send<span class=\"token punctuation\">(</span>message<span class=\"token punctuation\">)</span>\n\n        <span class=\"token keyword\">try</span><span class=\"token punctuation\">:</span>\n            <span class=\"token keyword\">await</span> self<span class=\"token punctuation\">.</span>app<span class=\"token punctuation\">(</span>scope<span class=\"token punctuation\">,</span> receive<span class=\"token punctuation\">,</span> send_with_header<span class=\"token punctuation\">)</span>\n        <span class=\"token keyword\">finally</span><span class=\"token punctuation\">:</span>\n            request_id_var<span class=\"token punctuation\">.</span>reset<span class=\"token punctuation\">(</span>token<span class=\"token punctuation\">)</span></code></pre></div>\n<p>The middleware does three things:</p>\n<ol>\n<li><strong>Adopt or mint the ID.</strong> If the client (or a load balancer, or an upstream service) already sent an <code class=\"language-text\">X-Request-ID</code>, adopt it so the ID spans service boundaries. Otherwise, mint a fresh one.</li>\n<li><strong>Echo it in the response.</strong> Wrapping <code class=\"language-text\">send</code> puts the same ID in the response headers. That is what lets an operator read the ID off a failed response and hand it to you.</li>\n<li><strong>Reset it afterwards.</strong> Calling <code class=\"language-text\">reset</code> on the contextvar in a <code class=\"language-text\">finally</code> block keeps the value from bleeding into whatever the worker handles next.</li>\n</ol>\n<p>One caution on adopting the header: you are now trusting a client-supplied string. If it flows straight into logs and dashboards, cap its length and character set. A caller who puts a newline or a few kilobytes in <code class=\"language-text\">X-Request-ID</code> is either fuzzing you or injecting fake log lines. I whitelist to something like <code class=\"language-text\">^[A-Za-z0-9_-]{1,64}$</code> and fall back to a generated UUID when it doesn’t match. The <a href=\"https://owasp.org/www-community/attacks/Log_Injection\">OWASP log injection notes</a> are the short version of why.</p>\n<h2>Getting the ID onto every log line</h2>\n<p>The middleware sets the value; now every log record has to read it. The clean way is a logging <a href=\"https://docs.python.org/3/library/logging.html#filter-objects\"><code class=\"language-text\">Filter</code></a>, which, despite the name, is allowed to modify the record as it passes through. The filter below attaches the current request ID as an attribute, and the formatter references it as <code class=\"language-text\">%(request_id)s</code>:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token comment\"># logging_setup.py</span>\n<span class=\"token keyword\">import</span> logging\n<span class=\"token keyword\">from</span> pythonjsonlogger <span class=\"token keyword\">import</span> jsonlogger\n<span class=\"token keyword\">from</span> context <span class=\"token keyword\">import</span> request_id_var\n\n<span class=\"token keyword\">class</span> <span class=\"token class-name\">RequestIdFilter</span><span class=\"token punctuation\">(</span>logging<span class=\"token punctuation\">.</span>Filter<span class=\"token punctuation\">)</span><span class=\"token punctuation\">:</span>\n    <span class=\"token keyword\">def</span> <span class=\"token function\">filter</span><span class=\"token punctuation\">(</span>self<span class=\"token punctuation\">,</span> record<span class=\"token punctuation\">:</span> logging<span class=\"token punctuation\">.</span>LogRecord<span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token builtin\">bool</span><span class=\"token punctuation\">:</span>\n        record<span class=\"token punctuation\">.</span>request_id <span class=\"token operator\">=</span> request_id_var<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n        <span class=\"token keyword\">return</span> <span class=\"token boolean\">True</span>  <span class=\"token comment\"># never actually drop the record</span>\n\n<span class=\"token keyword\">def</span> <span class=\"token function\">configure_logging</span><span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token boolean\">None</span><span class=\"token punctuation\">:</span>\n    handler <span class=\"token operator\">=</span> logging<span class=\"token punctuation\">.</span>StreamHandler<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n    handler<span class=\"token punctuation\">.</span>addFilter<span class=\"token punctuation\">(</span>RequestIdFilter<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span>\n    handler<span class=\"token punctuation\">.</span>setFormatter<span class=\"token punctuation\">(</span>\n        jsonlogger<span class=\"token punctuation\">.</span>JsonFormatter<span class=\"token punctuation\">(</span>\n            <span class=\"token string\">\"%(asctime)s %(levelname)s %(name)s %(request_id)s %(message)s\"</span>\n        <span class=\"token punctuation\">)</span>\n    <span class=\"token punctuation\">)</span>\n    root <span class=\"token operator\">=</span> logging<span class=\"token punctuation\">.</span>getLogger<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n    root<span class=\"token punctuation\">.</span>handlers <span class=\"token operator\">=</span> <span class=\"token punctuation\">[</span>handler<span class=\"token punctuation\">]</span>\n    root<span class=\"token punctuation\">.</span>setLevel<span class=\"token punctuation\">(</span>logging<span class=\"token punctuation\">.</span>INFO<span class=\"token punctuation\">)</span></code></pre></div>\n<p>I emit JSON here on purpose. Plain-text logs are readable at your terminal and miserable in a log store. OpenSearch and the Elastic stack both want structured fields, so you can filter on <code class=\"language-text\">request_id</code> instead of grepping a string out of a message blob. The <a href=\"https://github.com/nhairs/python-json-logger\"><code class=\"language-text\">python-json-logger</code></a> formatter turns each record into one JSON object per line, and every key becomes a field you can query. A log line now looks like this:</p>\n<div class=\"gatsby-highlight\" data-language=\"json\"><pre class=\"language-json\"><code class=\"language-json\"><span class=\"token punctuation\">{</span><span class=\"token property\">\"asctime\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"2026-09-23 14:05:02,113\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"levelname\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"INFO\"</span><span class=\"token punctuation\">,</span>\n <span class=\"token property\">\"name\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"archi.retrieve\"</span><span class=\"token punctuation\">,</span> <span class=\"token property\">\"request_id\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"9f2c1a7b4e\"</span><span class=\"token punctuation\">,</span>\n <span class=\"token property\">\"message\"</span><span class=\"token operator\">:</span> <span class=\"token string\">\"retrieved 8 candidates in 41ms\"</span><span class=\"token punctuation\">}</span></code></pre></div>\n<p>Every line that request produces, from any module, carries <code class=\"language-text\">9f2c1a7b4e</code>. The diagram below is the whole story: the ID is set once at the edge, a contextvar carries it, and the filter stamps it onto records that nobody had to thread it through.</p>\n<p><img src=\"/5631033ce3e336e7ddece343c700e30a/correlation-id-flow.svg\" alt=\"A left-to-right flow diagram. A client, which may send an X-Request-ID header, calls an ASGI middleware that adopts the header or mints a fresh UUID and sets a contextvar. The route handler runs business logic and forwards the ID on its downstream httpx call, and echoes the ID back in the response header. A logging filter reads the same contextvar and stamps every log record; those records serialize to JSON lines that land in OpenSearch, where you filter one request out by its request_id field.\"></p>\n<h2>Carry it across the network</h2>\n<p>Inside one service, the contextvar does the work. The ID earns its keep when it crosses into the <em>next</em> service, so forward it on outbound calls. With <a href=\"https://www.python-httpx.org/\">httpx</a>, an event hook (a function the client runs on every request) stamps the header without touching any call sites:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> httpx\n<span class=\"token keyword\">from</span> context <span class=\"token keyword\">import</span> request_id_var\n\n<span class=\"token keyword\">async</span> <span class=\"token keyword\">def</span> <span class=\"token function\">_propagate</span><span class=\"token punctuation\">(</span>request<span class=\"token punctuation\">:</span> httpx<span class=\"token punctuation\">.</span>Request<span class=\"token punctuation\">)</span> <span class=\"token operator\">-</span><span class=\"token operator\">></span> <span class=\"token boolean\">None</span><span class=\"token punctuation\">:</span>\n    request<span class=\"token punctuation\">.</span>headers<span class=\"token punctuation\">[</span><span class=\"token string\">\"X-Request-ID\"</span><span class=\"token punctuation\">]</span> <span class=\"token operator\">=</span> request_id_var<span class=\"token punctuation\">.</span>get<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n\nclient <span class=\"token operator\">=</span> httpx<span class=\"token punctuation\">.</span>AsyncClient<span class=\"token punctuation\">(</span>event_hooks<span class=\"token operator\">=</span><span class=\"token punctuation\">{</span><span class=\"token string\">\"request\"</span><span class=\"token punctuation\">:</span> <span class=\"token punctuation\">[</span>_propagate<span class=\"token punctuation\">]</span><span class=\"token punctuation\">}</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>The downstream service’s own middleware adopts that header instead of minting a new one, so the same ID now spans both services’ logs. You can chase one operator report across the retrieval service and the model gateway with a single filter.</p>\n<h2>Where it bites</h2>\n<p><strong>The thread-pool boundary is the big one.</strong> contextvars propagate to tasks you <code class=\"language-text\">await</code>, but not automatically to code you push onto a thread pool. If a handler calls a blocking function via <code class=\"language-text\">run_in_executor</code> or <code class=\"language-text\">starlette.concurrency.run_in_threadpool</code>, the log lines from inside that function come out with the default <code class=\"language-text\">-</code>. (<code class=\"language-text\">run_in_threadpool</code> is what sync <code class=\"language-text\">def</code> route handlers and dependencies, as opposed to <code class=\"language-text\">async def</code> ones, use under the hood.) FastAPI’s own <code class=\"language-text\">run_in_threadpool</code> copies the context for you, so plain sync endpoints are fine; a bare <code class=\"language-text\">loop.run_in_executor(pool, fn)</code> is not.</p>\n<p>The fix is to run the function inside a copy of the current context, using <code class=\"language-text\">contextvars.copy_context()</code>:</p>\n<div class=\"gatsby-highlight\" data-language=\"python\"><pre class=\"language-python\"><code class=\"language-python\"><span class=\"token keyword\">import</span> contextvars<span class=\"token punctuation\">,</span> functools\n\nctx <span class=\"token operator\">=</span> contextvars<span class=\"token punctuation\">.</span>copy_context<span class=\"token punctuation\">(</span><span class=\"token punctuation\">)</span>\n<span class=\"token keyword\">await</span> loop<span class=\"token punctuation\">.</span>run_in_executor<span class=\"token punctuation\">(</span>pool<span class=\"token punctuation\">,</span> functools<span class=\"token punctuation\">.</span>partial<span class=\"token punctuation\">(</span>ctx<span class=\"token punctuation\">.</span>run<span class=\"token punctuation\">,</span> blocking_fn<span class=\"token punctuation\">)</span><span class=\"token punctuation\">)</span></code></pre></div>\n<p>I once lost an afternoon to logs from a sync database driver showing <code class=\"language-text\">-</code> while everything else in the same request had the real ID. This was why.</p>\n<p><strong>Uvicorn’s access log is a separate stream.</strong> The <code class=\"language-text\">GET /answer 200</code> line comes from <code class=\"language-text\">uvicorn.access</code>, which never touches your contextvar. It won’t carry the ID unless you attach your filter and formatter to that logger too. It is easy to forget, and then half your “request done” lines are the ones missing the ID.</p>\n<p><strong>Background tasks outlive the request.</strong> A Starlette <code class=\"language-text\">BackgroundTask</code> runs after the response is sent, and depending on how you scheduled it, the contextvar may already be reset. To log the background work under the same ID, capture the value in a local variable while you are still inside the request and pass <em>that</em> in, rather than reading the contextvar from inside the task.</p>\n<p><strong>IDs are for correlation, not counting.</strong> A <code class=\"language-text\">uuid4</code> is random, so treat it as an opaque label: don’t parse it, don’t assume it’s globally unique forever, and don’t build logic on it. It exists to answer “show me this one request”, nothing more.</p>\n<h2>This is not distributed tracing</h2>\n<p>A correlation ID and a distributed trace solve overlapping problems, so it is worth being clear where the line is. The ID gives you a flat filter: every log line for one request, in one query. It does not give you the <em>shape</em> of the request: which call was slow, what nested inside what, where the 300ms went. That is what spans (the timed, nested units of work in a trace) are for. I wrote a separate post on <a href=\"/blog/2026-08-23-opentelemetry-tracing-rag-pipeline/\">tracing an LLM pipeline with OpenTelemetry</a> that covers the heavier machinery.</p>\n<p>The honest tradeoff: a correlation ID is a couple of dozen lines of code and zero new infrastructure, and on its own it answers 80% of “what happened to this request” questions. Full tracing needs a collector, a backend, and instrumentation on every hop. It earns that cost once you are debugging latency across several services, rather than reconstructing one request’s log story.</p>\n<p>If you already run OpenTelemetry, reuse the <a href=\"https://www.w3.org/TR/trace-context/\">trace context</a> <code class=\"language-text\">trace_id</code> as your correlation ID instead of minting a parallel one, so your logs and traces join on the same key. Start with the correlation ID, and add tracing when the flat view stops being enough.</p>\n<h2>Reading it back in OpenSearch</h2>\n<p>Once these JSON lines are shipped to OpenSearch, the payoff is a one-line query. In Dashboards, <code class=\"language-text\">request_id: &quot;9f2c1a7b4e&quot;</code> pulls that request’s entire life across every service, in order, and nothing else. That is the exact query I run when an operator quotes an ID back from a failed response.</p>\n<p>It is also why the JSON step isn’t optional. <code class=\"language-text\">request_id</code> has to be a real field for that filter to work, and a grep over a text blob won’t cut it once you are at any volume. For the operator consoles behind <a href=\"/project/cms-workflow-operations/\">CMS workflow operations</a>, where logs from several services land in the same OpenSearch cluster, this one field is the difference between a two-minute lookup and a wild afternoon.</p>\n<h2>What I’d do differently</h2>\n<p>If I were wiring this up on a new service today, I’d reach for <a href=\"https://github.com/snok/asgi-correlation-id\"><code class=\"language-text\">asgi-correlation-id</code></a> rather than hand-rolling the middleware. It is the same idea, it handles the header adoption, validation, and contextvar plumbing, and it has already been bitten by the thread-pool edge case so you don’t have to be.</p>\n<p>I still like writing it out by hand the first time. When the ID goes missing at 2am, you want to understand every line between the request and the log, not debug someone else’s abstraction. Either way, the shape is the same: set the ID once at the edge, carry it in a contextvar, stamp it on every line, and forward it on the way out.</p>\n<hr>\n<p>Related posts: <a href=\"/blog/2026-08-23-opentelemetry-tracing-rag-pipeline/\">Tracing an LLM pipeline with OpenTelemetry</a> · <a href=\"/blog/2026-07-31-graceful-shutdown-fastapi-kubernetes/\">Graceful shutdown for FastAPI on Kubernetes</a> · <a href=\"/blog/2026-09-03-opensearch-ism-index-lifecycle/\">OpenSearch ISM: automate index rollover</a></p>","frontmatter":{"title":"Correlation IDs in FastAPI Logs","date":"2026-09-23T00:00:00.000Z","description":"One async request scatters a dozen log lines through concurrent traffic. Thread a correlation ID through FastAPI with contextvars and trace it in OpenSearch.","thumbnail":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAALCAIAAADwazoUAAAACXBIWXMAAAsSAAALEgHS3X78AAACJklEQVQozyWPSU/bUBSFvSKTE89+o5+fx3hIXjzFMSFAIAwFytSq3SGx6C/othK/vUZIR2dx7v2kcyREdoh+CuBtkt8X1asonkX5kovHXqJ4WayessV3UbyK8jUXT3F6139+IdJYxSMFDWdwolEvqeLlJhFdWmyj5SbM1qI5T4uTPvSzKszX8XLtzlcDBQym1lAB0kRjKpgrIJxZngKiWS87nBi+bAWy4Y1VNlKoZi0w3WHaQdxhcszcHYCtYZXSRHWg18arl2X3Hizu3XjPoj1gG4s2Kkj7XmOFGPYyDG7z/CEIrqLoRognn19Z1qaHmUmaYvV2d/hIkh+E793g2vEOmJ2ZqFaMeKRAWfMNvNJwToIGeaVs9gXnCoz7zVTHgiftotmSsLbcGvC1xSoVpzoUBB1klU81H7DWdAQOGhzUyK8grzSUSxOFWrQVq7eri3/z9KfjXfLwlvILSE8sXCNwjtG5YQmbNbZb8OTYy7Zu0kG/1nAm9ZNMXCWL38e7v378iNxT4u0xO7VpZ6KSwIOqJ7IWAFIBmjO/csMau8KEiQnn0niGTFKm2fNp9yeIbiHbEn5GeA+3Bix1Iz+STV2jnZdueFRRv3XDHDopoBlgn7DtbKr6/eHbR7b4xfxrP7qn/BI7ZzpYDmVzNIXjKdRVTFHIacadzCFzAkNNxdJQtqeGp6PUdoQG54oVKnbU+8wMJprTX790NLGPZHswBQMZfHmf/Ac+i1te6gdHzAAAAABJRU5ErkJggg==","aspectRatio":1.899441340782123,"src":"/static/fd1d62f8a9974fed0f894db38d842403/40a76/hero.png","srcSet":"/static/fd1d62f8a9974fed0f894db38d842403/c972b/hero.png 340w,\n/static/fd1d62f8a9974fed0f894db38d842403/27625/hero.png 680w,\n/static/fd1d62f8a9974fed0f894db38d842403/40a76/hero.png 1360w,\n/static/fd1d62f8a9974fed0f894db38d842403/ed396/hero.png 2000w","sizes":"(max-width: 1360px) 100vw, 1360px"}}}}}},"pageContext":{"slug":"/2026-09-23-fastapi-correlation-id-logging/","previous":"blog/2026-09-24-roaring-bitmaps-fast-filters/","next":"blog/2026-09-22-llm-model-routing-cut-cost/"}},"staticQueryHashes":["32046230"]}