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: True

Common 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");