Debugging and Diagnostics
Debugger.Break() and Debugger.IsAttached let code cooperate with the debugger; [DebuggerDisplay("{Code} {Firmware}")] improves how objects appear in the watch windows.
Concept
Debugging in the IDE Breakpoint (F9) Pause at a line Conditional breakpoint
Pause only when frame.Id == 0x7E8 or on the n-th hit Tracepoint / log point Print a message without stopping
Step Over (F10) / Step Into (F11) / Step Out (Shift+F11)
Execute line by line, enter a call, or finish the current method Locals / Autos / Watch Inspect and evaluate variables Call Stack
How execution arrived here (including async call chains) Immediate window / Debug Console
Evaluate expressions and call methods while paused Exception settings
Break when an exception is *thrown*, not only when unhandled Attach to process
Debug an already running app (a service on a bench PC) Remote debugging
Visual Studio Remote Tools / vsdbg on another machine or container
Debugger.Break() and Debugger.IsAttached let code cooperate with the debugger; [DebuggerDisplay("{Code} {Firmware}")] improves how objects appear in the watch windows.
Diagnostics in Code Debug output and assertions Debug.WriteLine, Debug.Assert — removed from Release builds ([Conditional("DEBUG")]); Trace.* stays
[CallerMemberName], [CallerFilePath], [CallerLineNumber] ex.StackTrace, new StackTrace(ex) Tracing (distributed)
ActivitySource / Activity = OpenTelemetry spans with trace ID, parent, tags, status
Meter → Counter<T>, Histogram<T>, UpDownCounter<T>, ObservableGauge<T>
High-performance structured events consumed by EventListener, ETW, dotnet-trace
In-process diagnostic events (used by ASP.NET Core and HttpClient) Runtime counters
GC, thread pool, exceptions — dotnet-counters monitor -n MyApp
dotnet-counters (live metrics), dotnet-trace (CPU/event traces, viewable in PerfView or Visual Studio), dotnet-dump (capture and analyse memory dumps: dumpheap -stat, gcroot), dotnet-gcdump, and IDE profilers. Install with dotnet tool install -g dotnet-counters.
Why It Matters
Failures on a HIL bench at night, in a customer's garage or in a container cannot be stepped through in an IDE: logs, traces, metrics and dumps are the only evidence. The Activity and Meter APIs are what OpenTelemetry exporters (Module 59) send to Jaeger, Prometheus or Application Insights.
Syntax
Debug.Assert(frame.Length <= 8, "CAN frame too long");
static readonly ActivitySource Source = new("SampleApp.Flash");
using Activity? span = Source.StartActivity("FlashEcu"); span?.SetTag("ecu", "VCU");
static readonly Meter Meter = new("SampleApp.Bench");
Counter<long> frames = Meter.CreateCounter<long>("can.frames"); frames.Add(1);Example
48.1 Debug output, caller info, stack traces, activities, metrics and EventSource
#:property PublishAot=false
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Diagnostics.Tracing;
using System.Runtime.CompilerServices;
// ---- Debug output and assertions (compiled only in Debug builds: [Conditional("DEBUG")]) ----
Trace.Listeners.Add(new ConsoleTraceListener()); // route Debug/Trace output to the console
Debug.WriteLine("Debug.WriteLine: visible in Debug builds (and in the IDE Output window)");
Trace.WriteLine("Trace.WriteLine: also in Release builds");
Console.WriteLine($"debugger attached: {Debugger.IsAttached}");
// ---- Caller information and stack traces ----
Where();
try { Sensor.Level1(); }
catch (Exception ex)
{
var stackFrames = new StackTrace(ex).GetFrames().Select(f => f.GetMethod()!.Name);
Console.WriteLine($"exception stack: {string.Join(" <- ", stackFrames)}");
}
// ---- Distributed tracing with Activity (the .NET implementation of OpenTelemetry spans) ----
var source = new ActivitySource("SampleApp.Flash");
using var activityListener = new ActivityListener
{
ShouldListenTo = s => s.Name == "SampleApp.Flash",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDa
…Expected Result
Debug.WriteLine printed because dotnet run builds the Debug configuration; in a Release build that line disappears. Spans stop from the inside out (Erase, Write, then the parent FlashEcu), and all three share one trace ID — which is how a distributed trace ties the steps of one operation together across services.
Debug.WriteLine: visible in Debug builds (and in the IDE Output window)
Trace.WriteLine: also in Release builds
debugger attached: False
called from '<Main>$' in m48_diagnostics.cs line 14
exception stack: Level2 <- Level1 <- <Main>$
span Erase parent FlashEcu tags blocks=3 status Unset same trace: True
span Write parent FlashEcu tags bytes=196608 status Error same trace: True
span FlashEcu parent - tags ecu=VCU status Unset same trace: True
metrics: can.frames total 750, uds.latency count 3, max 30.25 ms
event TestStarted (Informational) payload: name=FlashVcu
event TestFinished (Warning) payload: name=FlashVcu, passed=False
GC gen0 collections so far >= 0: True, working set > 0: True, threads > 0: TrueCommon Mistakes
Debugging by adding Console.WriteLine everywhere and forgetting to remove it.
Putting side effects inside Debug.Assert(...) — they vanish in Release builds.
Catching exceptions and discarding the stack trace (throw ex; instead of throw;, Module 20).
Creating ActivitySource/Meter per call instead of once (static).
High-cardinality metric tags (VIN, request ID) that explode metric storage.
Only testing Debug builds — Release optimizations can expose race conditions.
Best Practices
Learn conditional breakpoints, "break when thrown" and the Parallel Stacks window.
Instrument long operations with Activity and key counts/latencies with Meter; export with OpenTelemetry.
Keep symbols (PDBs) for released builds so dumps and stack traces are readable.
Reproduce a bug with a failing test before fixing it.
Capture a dump (dotnet-dump collect) for hangs and memory leaks rather than guessing.
Interview Relevance
Medium–high: "how do you debug a production issue you can't reproduce locally?", "what is distributed tracing?", "logs vs metrics vs traces?", "how would you find a memory leak in .NET?", "Debug vs Trace?".
Practice Questions
Add [DebuggerDisplay] to a CanFrame record and inspect a list of frames in the debugger.
Create an ObservableGauge<double> for battery voltage and read it with a MeterListener.
Use dotnet-counters monitor on a program that allocates in a loop and watch the GC counters.
Start a parent activity, call an HttpClient endpoint, and observe the traceparent header sent automatically.
Quick Quiz
Q1. (Multiple choice) Which call remains in Release builds? A) Debug.WriteLine B) Debug.Assert C) Trace.WriteLine D) None Answer: C.
Q2. (Predict the output) A parent activity has two children. In which order do their ActivityStopped callbacks run?
Answer: The children first, then the parent (verified: Erase, Write, FlashEcu).
Q3. (True/False) [CallerLineNumber] is filled in by the compiler at the call site.
Answer: True (verified: line 14).
Q4. (Find the bug) Debug.Assert(queue.TryDequeue(out var frame)); then Process(frame);
Answer: In Release builds the whole Debug.Assert call — including TryDequeue — is removed, so nothing is dequeued.
Coding Challenge
Create a Meter with a Histogram<double> named uds.latency, record five latencies, and use a MeterListener to print their count, minimum and maximum.
using System.Diagnostics.Metrics;
using var meter = new Meter("SampleApp.Challenge");
Histogram<double> latency = meter.CreateHistogram<double>("uds.latency", unit: "ms");
var values = new List<double>();
using var listener = new MeterListener
{
InstrumentPublished = (instrument, l) => { if (instrument.Meter == meter) l.EnableMeasurementEvents(instrument); }
};
listener.SetMeasurementEventCallback<double>((_, value, _, _) => values.Add(value));
listener.Start();
foreach (double ms in new[] { 12.0, 7.5, 40.25, 9.0, 11.0 }) latency.Record(ms);
Console.WriteLine($"count {values.Count}, min {values.Min()} ms, max {values.Max()} ms");