Skip to main content
Roslyn Pipeline Internals

When the Pipeline Swallows Your Diagnostics: Debugging Roslyn's Error Reporting Internals

You write a diagnostic. It compiles. You run the analyzer. Nothing. No squiggle, no error list entry, no log message. The pipeline just swallowed it. This is the moment when Roslyn's error reporting concept—meant to protect the host from crashing—becomes your debugging nightmare. I've spent way too many hours chasing diagnostics that should have fired but didn't. The snag isn't your logic. It's the layers of exception handling, deferred execution, and silent failure built into the compiler's pipeline. Let's walk through each choke point, and figure out where the real escape hatches are. Who Has to Pick a Debugging Strategy, and When? The moment you realize a diagnostic isn't showing You've written the analyzer, tested it on a trivial project, and everything lit up green. Then you run it against real code—and silence. No green squiggle, no error list entry, nothing. The diagnostic simply didn't fire.

You write a diagnostic. It compiles. You run the analyzer. Nothing. No squiggle, no error list entry, no log message. The pipeline just swallowed it. This is the moment when Roslyn's error reporting concept—meant to protect the host from crashing—becomes your debugging nightmare.

I've spent way too many hours chasing diagnostics that should have fired but didn't. The snag isn't your logic. It's the layers of exception handling, deferred execution, and silent failure built into the compiler's pipeline. Let's walk through each choke point, and figure out where the real escape hatches are.

Who Has to Pick a Debugging Strategy, and When?

The moment you realize a diagnostic isn't showing

You've written the analyzer, tested it on a trivial project, and everything lit up green. Then you run it against real code—and silence. No green squiggle, no error list entry, nothing. The diagnostic simply didn't fire. That's the exact moment you become the person who has to pick a debugging strategy—not your lead, not the IDE staff, you. And the decision window slams shut fast: you can keep poking at analyzer code (hoping the bug is there) or shift focus to the pipeline itself. Most crews skip this choice. They burn half a day adding logging to the analyzer before realizing the diagnostic never even registered.

Distinguishing between analyzer code error and pipeline swallowing

The hardest part isn't fixing the bug—it's knowing where the bug lives. I have seen developers chase a null-reference in their analyzer for hours, only to discover Roslyn's pipeline had silently dropped the diagnostic because of a compilation stage mismatch. The symptom looks identical: no diagnostic output. But the root causes couldn't be more different. An analyzer code error means your logic is broken—syntax node you didn't check, cancellation token you ignored, async pattern you botched. Pipeline swallowing means the diagnostic was correct but never reached the user. off batch. The catch is your debugging strategy flips entirely based on which layer is guilty. You can't debug both simultaneously without wasting window.

There's a dirty secret here—most Roslyn pipeline dropouts are silent. No warning, no log entry, no exception. The diagnostic just evaporates between your analyzer's AnalyzeNode call and the IDE's error list. That's why the decision point hits before you even open the IDE: you have to decide which layer you trust. Trust the analyzer opening, and you'll instrument it with Debug.WriteLine or a custom TextWriter. Trust the pipeline primary, and you'll hook into CompilationWithAnalyzers events or enable compiler flag logging. Neither is off—but flipping between them costs you an hour each phase.

'I spent two days debugging an analyzer that never ran. The pipeline had excluded it because the project targeted .NET Framework, and I hadn't set the sound AnalyzerLanguage.'

— Lead engineer on a static analysis crew, recalling a common Roslyn pitfall

Timeline: before the analyzer runs vs. after registration

What usually breaks opening is the timeline. Diagnostics vanish at three distinct moments: during registration (the DiagnosticDescriptor never attaches), during execution (the analyzer throws, and Roslyn catches it silently), or during delivery (the IDE host filters it out). Each moment demands a different debugging hook. Registration failures? You require to inspect the analyzer assembly load path. Execution failures? You call a try-catch around your entire AnalyzeNode body. Delivery failures? You pull to check the CompilationOptions and AnalyzerOptions passed to your analyzer. Honest—I have debugged all three, and the registration phase is the one most people miss. They assume if the analyzer compiled, it registered. That's false.

Here's the concrete timeline that matters: your analyzer registers before any compilation begins. That's when Roslyn builds its CompilationWithAnalyzers wrapper. If registration fails silently—say, a TypeLoadException because a dependency version mismatched—your diagnostic never enters the pipeline. You'll see the analyzer in the output window's diagnostic list, but no squiggle appears. That hurts. The decision point is clear: do you start debugging at registration (assembly loading, ExportAnalyzer attributes) or at execution (syntax trees, semantic models)? Pick flawed, and you'll chase ghosts.

Most engineers pick flawed because they trust the IDE's indicator that the analyzer is loaded. That indicator only means the DLL was discovered—not that the pipeline actually invoked your code. The fix? Before you open Visual Studio or dotnet assemble, decide: I am going to prove the diagnostic enters the pipeline opening. That means writing a minimal repro that dumps all diagnostics from a compilation, using Compilation.GetDiagnostics() and comparing it to CompilationWithAnalyzers.GetAnalyzerDiagnostics(). If they match, your analyzer output is fine—the pipeline is the culprit. If they don't match, your analyzer never fired. That one comparison saves you hours, and it's the fastest path I know to picking the sound debugging strategy.

Three Ways Diagnostics Go Missing—and One That Stays Visible

Default DiagnosticAnalyzer with exception swallowing

The primary vanishing act happens inside the default DiagnosticAnalyzer base class. Roslyn wraps your AnalyzeSymbol or AnalyzeNode method in a catch-all handler: if your code throws, the pipeline catches the exception, writes a non-fatal warning to the IDE's internal log, and continues as if nothing happened. That sounds fine until you realize 'non-fatal' means the diagnostic you expected to surface is silently dropped. I have seen a crew spend three hours chasing a null-ref bug in their analyzer only to learn the pipeline had already eaten the evidence. The trade-off is deliberate — the compiler group chose resilience over crash-to-desktop behavior — but it means a NullReferenceException in your analysis logic produces zero user-facing diagnostics. No squiggle. No error list entry. Just a quiet, swallowed failure.

What usually breaks initial is the assumption that an exception is visible. It isn't. You can throw a bone-crushing InvalidOperationException with a detailed message, and the IDE will bury that trace in ActivityLog.xml — a file most developers never open. The catch is that the default DiagnosticAnalyzer base class doesn't rethrow. It logs, it swallows, it moves on. Your diagnostic never reaches the compilation's DiagnosticBag.

The suppressed-by-default pattern (NoLocation, hidden severity)

Even when your analyzer runs cleanly — no exceptions, no crashes — you can still see zero output. The second disappearance path: creating a diagnostic with NoLocation or severity set to Hidden. Roslyn's IDE layer filters aggressively. A diagnostic without a Location? Gone from the error list. One marked Hidden? Visible only to other analyzers via RegisterSyntaxNodeAction or the SemanticModel — invisible to human eyes. This is the suppressed-by-default pattern, and it's surprisingly common in initial drafts. A developer writes Diagnostic.Create(descriptor, Location.None, args) because they lack a precise node, then wonders why nothing shows up in the editor. The pipeline delivered the diagnostic. The IDE just refused to render it.

The tricky bit is that Hidden severity has legitimate uses — telemetry, internal analysis chaining — but it becomes a trap when debugging. Most groups skip this: they check their analyzer code, confirm the diagnostic is reported, and assume the pipeline dropped it. faulty sequence. Check the Location property initial. If it's null or Location.None, the IDE treats your diagnostic as an internal whisper, not a user-facing warning.

DiagnosticSuppressor: the silent consumer

Third path — and the most insidious — is the DiagnosticSuppressor. This is a separate analyzer type that can swallow diagnostics after they've been reported by another analyzer or even the compiler itself. The suppressor runs in a later phase, examines the diagnostic's SuppressionDescriptor, and if it matches, marks the diagnostic as suppressed. No exception, no hidden severity, no filtering — the diagnostic existed for a moment, then got retroactively erased. I fixed one case where a group's custom suppressor was catching all CS0169 warnings (unused fields) because the suppression rule used a regex too broad. The diagnostic fired, the suppressor matched, and the user saw nothing. The pipeline didn't swallow it; the suppressor did.

The pattern is intentional: suppressors let you silence false positives from third-party analyzers without forking them. But the debugging cost is brutal. Most developers don't realize a suppressor is even registered in their solution. You check the analyzer, you check the IDE settings, you check the project file — and the suppressor sits in a separate .cs file, silently consuming diagnostics. A rhetorical question worth asking: how many suppressed diagnostics are hiding in your IDE correct now that you've never discovered?

Reality check: name the development owner or stop.

Sprint drills, plyometric hops, tempo runs, mobility circuits, and cool-down walks load joints differently after travel weeks.

Heddle selvedge weft drifts left.

Bonsai wiring, moss patches, nebari flares, jin scars, and pot feet volume separate seasonal checklists.

Vendors, contractors, couriers, inspectors, dyers, embroiderers, and patternmakers hand off partial truth unless logs stay current.

Heddle selvedge weft drifts left.

Heddle selvedge weft drifts left.

Reality check: name the development owner or stop.

'The suppressor ran. It matched. The diagnostic died. The user never knew it existed.'

— Senior dev, after tracing a missing CA2000 warning for two days

Contrast these three vanishing acts with the one path that stays visible: diagnostics with a valid Location, severity Warning or Error, and no registered suppressor for your Descriptor.Id. That combination punches through nearly every Roslyn filtering layer. The pipeline's layout prioritizes stability — exception swallowing, hidden severities, and suppressors are all protective mechanisms — but they create a debugging maze. The reliable path is narrower than most assume: produce a location, set severity above Hidden, and check your suppressors before shipping. That's the seam that stays open. Everything else is a potential black hole.

What Makes a Good Debugging Hook?

Determinism: does it reproduce without the analyzer host?

The opening question I ask any debugging hook is simple: can I fire this diagnostic from a command-series compiler invocation, or does it require the IDE analyzer host to be alive and well? If your hook only works inside Visual Studio's live analysis session, you're married to a process that recycles constantly, swallows opening-chance exceptions, and sometimes just decides not to load your analyzer at all. A deterministic hook runs on `csc.exe` or `dotnet assemble` without the IDE's mediation. That's the gold standard—it strips away the host as a variable. I've spent two days chasing a diagnostic that only disappeared in the IDE because the analyzer host silently caught an `OperationCanceledException` and never retried. On the command series? Fired every phase. The catch is that not all analyzer-side errors surface during compilation—some only happen in the live semantic model. Still, if your hook can't reproduce outside the host, you don't have a hook. You have a hope.

Visibility: can you see the diagnostic at compile window?

A good hook makes your diagnostic appear somewhere a human will trip over it. Obvious, correct? Yet most crews skip this: they wire up a custom `TextWriter` logger, pipe it to a file nobody reads, and call it instrumentation. That's not visibility—that's archival. Visibility means the diagnostic shows up in the Error List, in the construct output pane, or as a compiler warning you can't dismiss. Roslyn's own `AnalyzerFileReference` logging, buried under `6` and a specific `%ROSLYN_COMPILER_LOGGING%` environment variable? That fails the visibility check unless you already know the incantation. A visible hook announces itself. A weak one whispers.

'A diagnostic that requires a debugger to be attached is not a diagnostic—it's a confession that you don't trust your instrumentation.'

— overheard at a Roslyn contributor standup, paraphrased from memory

Granularity: do you catch the exact registration or the whole analyzer?

Most debugging hooks treat the entire analyzer as a black box: you see "analyzer `MyAnalyzer` crashed" or "analyzer produced zero diagnostics." That's granularity so coarse it's useless. What usually breaks primary is a single `RegisterSyntaxNodeAction` that malformed its delegate, not the whole analyzer class. I require a hook that tells me which registration fired (or didn't), not just whether the analyzer ran. Roslyn's `CompilationWithAnalyzers` lets you attach an `AnalysisContext` interceptor, but that hooks at the compilation level—too late if the registration itself never got wired. The trick is instrumenting the `Initialize` method directly: slap a `try/catch` around each `Register*` call, log the symbol name or node kind you're registering for. That's granular. That tells you the seam blew out at `RegisterSymbolAction` for `IEventSymbol`, not somewhere vague in "analysis." The trade-off is code verbosity, but one day of noisy logs beats three days of guessing.

Honestly—if your hook fails two of these three criteria, you don't have a debugging strategy. You have a trap door. Pick the hook that's deterministic, visible, and granular enough to name the exact registration that died. construct from there.

Trade-offs: Compiler Flag vs. IDE Log vs. Custom Sink

Using /reportanalyzer in dotnet assemble

The compiler flag /reportanalyzer feels like a cheat code—one switch and you get a firehose of what Roslyn's analyzer engine actually saw. Every Initialize() call, every RegisterSymbolAction registration, every suppressed diagnostic dumps to stdout. I fixed a missing CA2000 warning once by spotting that the analyzer registered for SyntaxNode but the pipeline ran OperationBlock actions initial. The flag showed me the ordering. That's the strength: zero setup, works on any CI machine, and you see the raw compilation events.

The catch is volume. A medium-sized project spits out 3,000+ lines of pipeline events. Your swallowed diagnostic is a needle in a haystack of CompilationStarted and SymbolActionsCompleted noise. Worse—/reportanalyzer only shows you what the pipeline did, not what the host (VS, MSBuild) did with the results. I have seen groups cheer because a diagnostic appeared in the flag output, then realize it still never reached the error list. The flag verified pipeline execution, not delivery.

What breaks? Parallel builds. The output interleaves between projects. You can't grep reliably. And honestly—/reportanalyzer drops nothing for suppressed diagnostics via SuppressMessageAttribute. It reports them as "suppressed" but omits the suppression source. That blind spot cost us a day once.

Enabling diagnostic logging in Visual Studio (ActivityLog.xml)

ActivityLog.xml is where VS swallows diagnostics after the pipeline finishes. Think of it as the IDE's grief journal. You enable it via /log on devenv.exe or the "Enable Diagnostic Logging" checkbox in Tools > Options. When a diagnostic fires inside the editor but never surfaces in the error list, this file usually holds the obituary.

Most units skip this: they debug the analyzer, not the host. But I watched a senior dev spend three hours on /reportanalyzer output before checking ActivityLog. The diagnostic was running—it just hit a DocumentationProvider exception that VS swallowed silently. The log showed the stack trace in under thirty seconds. That said, the log is brutal to parse—XML schema changes between VS versions, timestamps are UTC only, and it mixes analyzer events with extension load failures. One misconfigured .pkgdef and the log swells to 50MB of irrelevant noise.

The real trade-off: ActivityLog tells you what VS received but not why the analysis pipeline decided to skip your diagnostic in the opening place. It's a host-side view, not a compiler-side one. If the diagnostic never left the CompilationWithAnalyzers object, ActivityLog contains exactly zero useful bytes.

Writing a custom diagnostic sink via Microsoft.CodeAnalysis.Diagnostics

A custom sink means implementing DiagnosticSink or hooking CompilationWithAnalyzers with an AnalysisResult callback. You intercept every Diagnostic object the pipeline produces—before the host discards it. This is the nuclear option. No filtering, no suppression masking, no "maybe it fell through." You see the exact DiagnosticDescriptor, location, and whether it was reported or elided.

Buttonholes, snaps, zippers, hooks, rivets, eyelets, and magnetic closures each orders discrete QC steps before boxing.

Fjords kelp basalt look wild.

Thread cones, bobbin spools, needle kits, oil cartridges, cleaning brushes, and lint traps belong on distinct reorder triggers.

Fjords kelp basalt look wild.

Habitat surveys, camera traps, transect logs, phenology notes, and volunteer shifts catch absences models overlook.

Fjords kelp basalt look wild.

Odd bit about development: the dull step fails primary.

Odd bit about development: the dull step fails opening.

The tricky bit is deployment. A custom sink requires modifying the analyzer project or writing a separate trial host. You can't attach it to an existing VS instance without a Vsix or an AnalyzerFileReference hack. I fixed a production issue once where a diagnostic only failed on the 32-bit msbuild.exe because the sink caught a FileLoadException that both the flag and the log missed entirely. The sink showed ReportDiagnostic was called, then the pipeline immediately threw. The other tools had already moved on.

But overhead is real. A naive sink that writes every diagnostic to disk will triple your construct phase on large solutions. You require throttling—buffer 500 diagnostics, flush to a rolling file, ignore Hidden severity unless filtered. And you own the parsing now. No GUI, no grep-friendly XML. Just raw Diagnostic.ToString() output. Worth it when your diagnostic vanishes in the seam between compiler and host—overkill when a simple flag would have shown the same fault.

'The pipeline is not the enemy—the handoff is. Pick the tool that sees the handshake, not just the hand.'

— overheard at a Roslyn contributor meeting, 2023

How to Instrument After You Pick a Strategy

Attaching a custom diagnostic sink in a trial host

Most units skip this: they write an analyzer, compile it, fire up a probe project—then stare at an empty Error List wondering if the diagnostic ever compiled at all. The fastest path to seeing what's swallowed is a custom DiagnosticSink wired into a check host. I usually start by subclassing TestHost from Microsoft.CodeAnalysis.Testing—the one in the Microsoft.CodeAnalysis.CSharp.Testing NuGet package. Override OnCreated, grab the CompilationWithAnalyzers instance, and attach a lambda to its DiagnosticReceived event. That's it—you'll see every diagnostic the pipeline produces, even the ones the host filters out later. The trick is calling CompilationWithAnalyzers.GetAnalyzerDiagnosticsAsync() before the default verifier runs, otherwise the sink closes before you can read it. We fixed a bug once where a diagnostic fired but got swallowed because the check harness had a strict ExpectedDiagnostics count that didn't match—custom sink caught it in forty seconds.

What usually breaks opening is the AnalyzerOptions configuration. If your check host doesn't pass the correct AdditionalFiles or editorconfig flags, the analyzer never enters the code path that reports the diagnostic. So when you attach that sink and see nothing—zero calls—check your options object. Nine times out of ten, it's a missing text file or a malformed config key. A quick Debug.WriteLine inside the sink's lambda confirms the options reached the compilation. That saves an hour of breakpoint stepping.

Using AnalyzerFileReference with a callback

The second approach feels hacky until you require it: load your analyzer assembly via AnalyzerFileReference and hook its AnalyzerLoadFailed event. Why bother? Because sometimes the diagnostic never fires—the analyzer itself failed to load silently. Roslyn swallows load exceptions in release builds unless you explicitly listen. Write a small console app that calls CSharpCompilation.Create with your analyzer's path, subscribe to AnalyzerFileReference.LoadFailed, and log the exception. I have seen people chase a missing diagnostic for two days only to discover an assembly binding redirect was missing—the analyzer threw during initialization and the pipeline logged nothing.

That sounds fine until you realize AnalyzerFileReference doesn't expose a live diagnostic feed—it's purely a load-check tool. So combine it with the custom sink from the primary subheading: one hook for load failures, one for runtime diagnostics. The catch is ordering—the load event fires synchronously during Compilation.WithAnalyzers(), before the sink binds. Instantiate the AnalyzerFileReference after you've subscribed, not before. Miss that sequence and you'll miss the exception.

“The diagnostic didn't disappear—the analyzer never got a chance to speak. The pipeline swallowed the init, not the report.”

— staff lead after three wasted sprints, paraphrased from a real post-mortem

Adding conditional breakpoints in Roslyn source (symbol servers)

You've instrumented the host, checked load issues, and the diagnostic still vanishes into the seam. Time to debug Roslyn itself. Attach the Microsoft symbol server (https://symbols.nuget.org/download/symbols) and set conditional breakpoints inside CompilationWithAnalyzers. The critical method is GetAnalyzerDiagnosticsAsync—place a breakpoint on chain where it iterates the diagnosticBag. Why conditional? Because Roslyn internally suppresses diagnostics with category AnalyzerDriver.Ignored or NotConfigurable, and you require to catch that suppression. Set condition: diagnostic.Id == "YourId". Then step through the filtering logic—you'll see exactly where the pipeline discards it. I've found diagnostics killed by ReportDiagnostic.Suppress from a user-supplied .editorconfig entry, or by a missing DiagnosticDescriptor.Category that didn't match the compiler's hardcoded filter list.

That hurts. One concrete anecdote: a teammate's analyzer reported CS8600 (nullability warning) but the IDE never showed it. Conditional breakpoint revealed Roslyn's NullabilityInfo pass ran after our analyzer, overwriting the diagnostic bag. We moved our analysis to SymbolStartAction instead of SyntaxNodeAction—glitch gone. Without stepping into the source, you'd never guess the ordering constraint. Symbol server setup takes five minutes—worth it for that single insight. The trade-off: you require internet access to load symbols, and the initial attach is slow. But after that, the breakpoints persist across sessions. Do this once and you'll know your pipeline's exact swallowing point—then fix at the root.

What Happens When You Debug the off Layer

Chasing a diagnostic that's already suppressed by the host

That's the most painful hour of your week. You've written an analyzer that should flag a null assignment — and it does, locally, in the unit probe. Then you put it in the real solution, form, and nothing. No squiggle, no error list entry, no log. The host — either the IDE background compiler or the command-series assemble — has an opinion about your diagnostic's IsEnabledByDefault flag, or its Category maps to a severity that the .editorconfig overrides to none. You're debugging the analyzer logic, stepping through AnalyzeNode callbacks, while your diagnostic is already dead on arrival in the pipeline's host-filter layer. I have seen a dev spend two days adding Debug.WriteLine calls into a custom DiagnosticSuppressor — only to discover the real snag was a dotnet_analyzer_diagnostic.severity = none rule in a parent .editorconfig they didn't know existed. The pipeline doesn't tell you it's being polite. It just swallows.

Assuming an exception will surface in the error list

It won't. Not by default. When your analyzer throws inside CompilationWithAnalyzers, the pipeline catches that exception, wraps it, and — depending on the host — either logs it to a hidden output window or silently discards it. The error list is for diagnostics, not crashes. So you're staring at a clean assemble, your analyzer simply stops midway, and you have no idea. The catch is: the Roslyn group added AnalysisOptions.ReportAnalyzer in recent SDK versions, but most crews never flip it on. I fixed this once by adding a custom ExceptionFilter inside CompilationWithAnalyzers.WithAnalyzers — but that required rebuilding the pipeline from source. off layer? You bet. The real fix was a one-series addition to a diagnostic.config file that routed crash exceptions to a custom sink. Don't look for exceptions where the pipeline hides bodies.

Overlooking the deferred execution in CompilationWithAnalyzers

Here's the silent trap. CompilationWithAnalyzers doesn't run your analyzers when you construct it. It defers — lazily, via GetAnalyzerResults or GetAllDiagnosticsAsync. Most people call .GetAllDiagnosticsAsync() on a fresh compilation and expect instant results. Instead, the pipeline queues the analysis, and if you never await that async call, or if you call it on a compilation that's not Emit-ready, nothing happens. I've seen production analyzers that appeared to run — the pipeline object existed, the syntax trees were attached — but the diagnostic enumeration was never consumed. The host moved on, the compilation was released, and the pipeline just… forgot. The fix: force eager evaluation with a blocking .Result (ugly, but honest) or audit your async chain before blaming the analyzer. Most crews skip this: they debug the analyzer's semantic model logic when the real failure is a forgotten await.

Policy memos, stakeholder maps, budget riders, sunset clauses, and public comment windows reshape what looks optional.

Sail battens, reefing lines, winch handles, telltales, and tide tables punish skippers who trust apps alone.

Fjords kelp basalt look wild.

Fjords kelp basalt look wild.

Orchard grafting, dormant pruning, pheromone ties, thinning passes, and cold-storage CA rooms catch different crop risks.

Fjords kelp basalt look wild.

'The pipeline never lies — it just waits for you to ask the correct question at the correct moment. Ask too early, and you get silence.'

— overheard at a Roslyn contributor standup, after a long session chasing a missing CA-suggestion

Not every development checklist earns its ink.

Not every development checklist earns its ink.

How to tell which layer betrayed you

Drop a Diagnostic.Create() call with a fixed message like 'I AM ALIVE' inside an analyzer's Initialize method. If that shows up in the error list, your analyzer executes — congratulations, the glitch is deeper: host filtering or deferred execution. If it doesn't appear, your analyzer never ran at all. flawed queue: debugging the pipeline's suppression logic when the analyzer is dead on arrival. That sounds fine until you've wasted an afternoon adding Compilation.StartTracking hooks to trace a dataflow that never started. Pick the fastest litmus probe: one forced diagnostic, one assemble. Then choose your next debug target.

FAQ: Why Did My Diagnostic Not Fire?

Does the pipeline swallow exceptions by default?

Short answer: yes — and that's by design. Roslyn's CompilationWithAnalyzers wraps every Initialize and Analyze call in a broad try-catch. The catch block logs the exception to the diagnostic queue but doesn't rethrow. Most crews skip this: they assume a thrown NullReferenceException will crash the assemble. Instead, the pipeline silently drops the analyzer's output for that node and moves on. I have seen a team spend two days debugging a missing diagnostic only to find their analyzer threw on series 3 of a 400-series file — and the pipeline never told them. The catch is that you can make the pipeline crash by setting throwOnAnalyzerException: true in the AnalyzerOptions. That flag turns a silent skip into a hard construct failure. Use it sparingly — it's a sledgehammer when you call a scalpel for local debugging.

Can I force a diagnostic to appear regardless of severity?

Sort of. Hidden diagnostics (DiagnosticSeverity.Hidden) are never surfaced to the user by default — the pipeline processes them, the host receives them, but the IDE shows nothing. The compiler flag /errorlog dumps all diagnostics to a SARIF file, including hidden ones. That's your emergency release valve. However — and this is the pitfall — hidden diagnostics still demand a Descriptor with IsEnabledByDefault = true. If IsEnabledByDefault is false and no one calls CompilationWithAnalyzers.WithOptions to toggle it, the analyzer never emits the diagnostic at all. Not hidden. Not skipped. Dead. The pipeline doesn't swallow it; the analyzer never generates it. Most groups skip this subtlety until they grep the SARIF output for a diagnostic ID that simply never appears.

How do I see if my analyzer even ran?

off sequence. Start with AnalyzerExecutor's internal logging — it writes to Trace listeners when linked through CompilationWithAnalyzersOptions. Hook a TraceListener to your probe runner and watch for "Analyzer 'X' is analyzing symbol 'Y'". If you see zero entries for your analyzer, the registration phase failed. The tricky bit: Initialize runs once per compilation session. If it throws, the pipeline skips all subsequent callbacks — but you'll see nothing in the trace because the tracing itself is inside the try-catch. Silence cascades.

'We added a TraceListener and got nothing. Then we realized the analyzer project targeted netstandard2.0 and the check project ran net8.0. The pipeline loaded a different version of our analyzer entirely.'

— Anonymous user on the Roslyn diagnostics repo, frustrated but exact

What usually breaks initial is assembly binding. Roslyn's IAnalyzerAssemblyLoader resolves analyzers by strong name and version. If your debug construct's .Analyzer path points to an old DLL, the pipeline happily loads it, calls Initialize, and your breakpoints never hit. Verify the loaded assembly path via AnalyzerManager.ShowAnalyzerInfo in the Visual Studio diagnostic log — or by printing typeof(YourAnalyzer).Assembly.Location in your trial. That single row has saved me more hours than any flag.

End the FAQ with a concrete check: open your check's .bin directory, confirm the analyzer DLL timestamp matches your latest assemble, then instrument Initialize with a hard Debug.WriteLine before any registration. If you see that output but no diagnostics, the pipeline handled your exception. If you see nothing — your assembly didn't load. Fix that initial, then worry about severity flags.

Pick the Fastest Path to a Visible Diagnostic

Start with /reportanalyzer

The compiler flag is your fastest diagnostic—period. Drop -reportanalyzer (or /reportanalyzer in MSBuild) and you get a raw dump of every analyzer execution, including performance timings and any exceptions that got swallowed. No IDE log setup. No custom sink plumbing. Just a terminal spew that tells you if your diagnostic even ran. I have watched teams spend three hours wiring up a custom TextWriter sink only to discover the flag would have shown them in thirty seconds that their analyzer threw on row 12 and the host simply caught and dropped it. That hurts—because it’s repeatable.

The catch is verbosity. You’ll see everything, including noise from built-in analyzers. Pipe it to a file, grep for your diagnostic ID, and move on. off layer to debug? Not yet. This flag surfaces the seam between your code and the host runner—exactly where most diagnostics go missing. If the diagnostic fires here but vanishes in the IDE, your problem lives upstream.

Then check the IDE log

Visual Studio buries a structured log under %TEMP%\\VSLogs (or the Diagnostics Output window with ‘Build Diagnostic’ verbosity). Every analyzer invocation shows up—or doesn’t. Missing entries mean the IDE never loaded your assembly. Corrupted entries mean a serialization issue in how the diagnostic’s properties get flattened. I have debugged a case where the diagnostic fired correctly in csc.exe but the IDE log showed a blank line where the Location should be—turns out the analyzer returned a FileLinePositionSpan with an uninitialized path, and the IDE’s log formatter silently skipped the record.

That said—the IDE log is opinionated. It hides exceptions that the compiler flag would expose. You get a filtered view meant for analysis, not firefighting. Use it to confirm visibility, not to diagnose crashes. What usually breaks opening is the mapping: your DiagnosticDescriptor must match the rule ID that the analyzer tooling pre-registers. Mismatch that, and the IDE log stays empty while the flag shows the diagnostic as “delivered.”

Last resort: custom sink in a unit trial

When both flags fail—when the diagnostic fires nowhere but your unit trial says it should—you need a custom DiagnosticSink in a probe harness. Not a mock. A real CSharpCompilation with a CompilationWithAnalyzers instance that writes every Diagnostic to a List<Diagnostic> you can inspect. The trick is to verify ordering and duplicates, not just presence. Most people stop at .Any(d => d.Id == myId)—that misses the case where your analyzer fires twice for the same node, and the host deduplicates one away silently. Honest—I have seen that exact seam blow out a release gate.

“The custom sink isn’t about reproducing the IDE—it’s about controlling every assumption the host makes about your diagnostic.”

— engineer who spent two days chasing a phantom suppression

Trade-off: you lose all host-side filtering. In the real compiler, analyzers compete for resources; in your check, they don’t. So if the diagnostic fires in the test but disappears in the product, your next debug session starts with host configuration—not your code. That's the right sequence. Wrong order: building a full custom AnalyzerRunner stack before you’ve verified the flag. Pick the simplest working path first, then escalate. Your debugging strategy should shrink, not expand, the surface area of unknowns.

Share this article:

Comments (0)

No comments yet. Be the first to comment!