C# 14: Interceptors in practice with automatic correlation headers
Introduction
Interceptors became a stable C# language feature with C# 14 and .NET 10, after spending nearly two years in preview since their quiet introduction in C# 12. The [Experimental] attribute is gone, the opt-in <InterceptorsPreviewNamespaces> property in the .csproj is no longer required, and the API has been consolidated around GetInterceptableLocation().
Rather than explaining the mechanism in the abstract, this article builds a concrete solution around a problem every developer working in a distributed environment has faced: propagating correlation IDs across service boundaries through HttpClient. We will go through the usual approaches, show why they all carry some friction, and then implement an interceptor-based Source Generator that solves the problem transparently at compile time.
Why interceptors require a Source Generator
The first question most developers ask when discovering interceptors is: can I just write the [InterceptsLocationAttribute] by hand and be done with it? The short answer is no, and understanding why is key to understanding the whole design.
The encoded position is not human-writable
[InterceptsLocationAttribute] takes two arguments: a version integer and a data string. That data value looks like this:
// This is what Roslyn generates for a specific SendAsync call site
[InterceptsLocationAttribute(1, "qjmcoI/hUdYHdlM5/alrVYsBAABPcmRlclNlcnZpY2UuY3M=")]
That base64 string encodes the exact file path, line number, column, and a content hash of the call site. It is recalculated by the compiler on every build. If you rename the file, add a blank line above the call, or even reformat the code, the value changes entirely. There is no way to write or maintain this by hand, it must be computed programmatically at compile time, which is precisely what SemanticModel.GetInterceptableLocation() does inside the Source Generator.
If you try to hardcode the data value, your project will compile once and then silently break the next time any developer touches the intercepted file — with no compile error, just incorrect runtime behavior. The Source Generator is not optional: it is the only reliable way to keep these values in sync with the actual source code.
The compiler rejects interceptors outside declared namespaces
Even if you could write the attribute manually, the compiler enforces a second constraint: the interceptor method must live in a namespace declared in <InterceptorsNamespaces> in the consumer’s .csproj. This is deliberate, it prevents third-party packages from silently intercepting your code without your explicit opt-in. The Source Generator is the natural owner of that namespace, and it is the consumer project (not the generator) that decides to trust it by adding the namespace to its project file.
Source Generators vs other AOP approaches
Developers familiar with Castle Windsor, PostSharp, or even simple DelegatingHandler registrations often wonder why interceptors warrant all this machinery. The difference is fundamental:
- Castle Windsor / Autofac dynamic proxies — generate proxy classes at runtime using reflection and IL emit. They carry a runtime cost, require virtual methods or interfaces, and are incompatible with Native AOT.
- PostSharp / Fody IL weaving — rewrite the compiled IL after the build. They work without interfaces but operate on bytecode, not source, making the transformation invisible to the developer and the IDE.
- Interceptors — replace the call site during compilation, before any IL is produced. No runtime overhead, no reflection, no virtual dispatch. The substitution is baked directly into the binary, fully visible in the generated
.g.cs, and completely compatible with Native AOT.
This is also why the two-phase filter in the generator matters beyond style. The syntactic filter runs on every syntax node on every keystroke in the IDE, it must be allocation-free and branchless. The semantic filter resolves symbols and is only triggered on nodes that passed the first filter. Blending the two would make the IDE sluggish on large codebases. The separation is a hard performance contract, not a convention.
The problem: correlation headers and HttpClient
In any service-oriented architecture like Kubernetes, Tanzu, or otherwise, distributed tracing depends on a correlation ID being forwarded with every outbound HTTP call. The correlation ID is typically generated once per incoming request and stored in the ambient IHttpContextAccessor or a custom CorrelationContext. Every downstream call must carry it in a header such as X-Correlation-ID
The standard solutions each introduce a different kind of friction:
- DelegatingHandler: the idiomatic approach, but it requires registering a handler per named or typed client, and it is easy to forget on a new client registration.
- Manual header injection: scattered across call sites, error-prone, and completely invisible in code reviews.
- Middleware: only covers the inbound side, not outbound HttpClient calls.
What if the header injection happened automatically at compile time, on every HttpClient.SendAsync call in the codebase, with zero changes to the calling code? This is exactly the problem interceptors are designed for.
How interceptors work
An interceptor is a static method decorated with [InterceptsLocationAttribute] that replaces a specific method call site at compile time. The Roslyn compiler identifies the exact source position of the original call and routes it to the interceptor instead. No reflection, no dynamic proxy, no runtime overhead.
Interceptors are not written by hand, they are emitted by a Source Generator that inspects the compilation, finds the target call sites, and injects the substitution code as a generated file. The consumer project never changes.
Solution structure
The generator targets netstandard2.0, a hard requirement of the Roslyn analyzer API. Note the NoWarn suppression for RSEXPERIMENTAL002: even though interceptors are stable in C# 14, the InterceptableLocation type in Microsoft.CodeAnalysis.CSharp 4.12.0 still carries that diagnostic marker.
The consumer project has three important properties worth explaining. InterceptorsNamespaces declares which namespaces contain interceptors, this opt-in still exists in C# 14, it simply moved from InterceptorsPreviewNamespaces to its stable name. EmitCompilerGeneratedFiles writes the generated .g.cs to disk for inspection, and Compile Remove is essential to pair with it: without it, MSBuild would compile the file a second time from disk, causing CS0433 (duplicate type).
The CorrelationContext
The interceptor needs access to the current correlation ID at runtime without depending on IHttpContextAccessor or any DI container. An AsyncLocal<T>-backed static class is the right tool: ambient, thread-safe, and flows correctly across async/await continuations.
The consumer code, unchanged
The Source Generator
The generator uses a two-phase filter. The syntactic filter runs on every node in the syntax tree, it must be fast and allocation-free, checking only the method name as text. The semantic filter resolves the actual symbol to confirm it belongs to System.Net.Http.HttpClient, and critically, skips any file ending in .g.cs or .generated.cs to prevent the generator from intercepting its own generated SendAsync calls, which would produce redundant interceptors on every subsequent build.
The Generate method builds the substitution code. The attribute is constructed from location.Version and location.Data, the two properties that encode the exact call site position. InterceptsLocationAttribute is declared as a file sealed class inside the generated file itself, scoped to that file only. This avoids any naming conflict with other generators emitting the same attribute.
What Roslyn actually injects
After build, the generated file is written to MyApi/Generated/…/HttpClientInterceptors.g.cs. It is visible in Visual Studio only with Show All Files enabled in Solution Explorer, since <Compile Remove=”Generated\**”/> hides it from the normal project view. For the two call sites in OrderService.cs, it looks like this:
Both classes use the file modifier, InterceptsLocationAttribute and HttpClientInterceptors are completely invisible outside this generated file. No naming conflicts, no leakage into the public API.
Pitfalls learned the hard way
Building this solution surfaced four non-obvious issues worth documenting explicitly.
Skip your own generated files. Without the .g.cs suffix check in GetTargetOrNull, the generator detects the client.SendAsync() calls inside its own generated interceptors and produces new interceptors for them on the next build — which themselves get intercepted, and so on. Always guard against this by checking invocation.SyntaxTree.FilePath early in the semantic filter.
Compile Remove is mandatory with EmitCompilerGeneratedFiles. When EmitCompilerGeneratedFiles=true, MSBuild writes the .g.cs to disk and then picks it up as a regular source file — compiling it a second time alongside the in-memory Roslyn injection. This causes CS0433 (duplicate type). The <Compile Remove=”Generated\**”/> tells MSBuild to ignore the folder at compile time while keeping the files on disk for inspection.
Do not cast to CSharpSemanticModel. That type is internal in Roslyn. GetInterceptableLocation is a public extension method on SemanticModel, no cast needed.
record does not work on netstandard2.0 without a polyfill for IsExternalInit. Use a plain class with explicit properties and a constructor instead.
Comparing the approaches
| Criterion | DelegatingHandler | Manual header | Interceptor |
|---|---|---|---|
| Business code modified | No | Yes, every call site | No |
| Requires DI registration | Yes — per client | No | No |
| Easy to forget on new clients | Yes | Yes | No, compile-time |
| Works on any HttpClient instance | Only registered ones | Yes | Yes, all call sites |
| Native AOT compatible | Yes | Yes | Yes |
| Runtime overhead | Minimal | None | None |
Conclusion
Interceptors shine on exactly this kind of problem: a cross-cutting concern that must apply uniformly to a specific call, across an entire codebase, without polluting business code with infrastructure logic. The DelegatingHandler approach is idiomatic and correct, but it requires discipline, a developer registering a new HttpClient can silently bypass it. The interceptor approach makes the omission impossible at compile time.
The working recipe in C# 14: generator targets netstandard2.0 with RSEXPERIMENTAL002 suppressed, consumer declares InterceptorsNamespaces and Compile Remove=”Generated\**”, the generator filters out .g.cs files to avoid self-interception, and InterceptsLocationAttribute is declared inline as a file sealed class in the generated output.
You can download the full repo here: https://github.com/AnthonyGiretti/dotnet10-correlationid-interceptor-demo

