Attaching a debugger to a production process is rarely an option, and "add more logging, redeploy, wait for it to happen again" is a slow way to chase an intermittent issue. The dotnet-trace, dotnet-counters, and dotnet-dump global tools solve this — they attach to a running process, capture what you need, and detach, with no code changes and no restart.
- All three tools attach to an already-running process by PID — no code changes, no redeploy, no restart required.
dotnet-countersgives you a live view of GC, thread pool, and request metrics; start there for “something's wrong but I don't know what.”dotnet-tracecaptures a CPU/event trace you can analyze offline in Speedscope or PerfView; use it once counters point at a specific symptom.dotnet-dumpcaptures a full memory snapshot for offline analysis — the right tool for memory leaks and hangs, not CPU issues.
Installing the tools
dotnet tool install --global dotnet-trace
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
# find the process you want to inspect
dotnet-counters ps
In a container, install these into the image (or a debug sidecar) ahead of time — you don't want the first attempt at installing a diagnostic tool to happen during an active incident.
dotnet-counters: start here
When something's slow or using too much memory with no specific lead yet, dotnet-counters gives you a live terminal dashboard of GC generation counts, heap size, thread pool queue length, and exception rate — usually enough to point you at GC pressure, thread pool starvation, or neither within a couple of minutes.
dotnet-counters monitor --process-id 4821 System.Runtime
You're not limited to the built-in runtime counters. The System.Diagnostics.Metrics.Meter API lets you publish your own application-specific counters — queue depth, cache hit rate, orders processed per minute — and dotnet-counters will pick them up alongside the runtime metrics with zero extra tooling.
private static readonly Meter Meter = new("Astrivo.OrderProcessing");
private static readonly Counter<long> OrdersProcessed =
Meter.CreateCounter<long>("orders.processed");
public void Process(Order order)
{
OrdersProcessed.Add(1, new KeyValuePair<string, object?>("status", order.Status.ToString()));
}
dotnet-trace: when counters point at CPU
Once counters tell you CPU or a specific code path is the problem, capture a trace to see exactly where time is going.
dotnet-trace collect --process-id 4821 --duration 00:00:30
# open the resulting .nettrace file at speedscope.app,
# or analyze it directly with PerfView on Windows
A 30-second trace under real load is usually enough to spot a hot method that's disproportionately expensive, or confirm that time is going somewhere unexpected — serialization, a regex compiled per-request instead of cached, an accidental synchronous call in an async path.
dotnet-dump: memory leaks and hangs
For a slow memory leak or a process that's stopped responding, a live memory snapshot beats guessing.
dotnet-dump collect --process-id 4821
dotnet-dump analyze core_20260714_091500
> dumpheap -stat # what's on the heap, sorted by size
> dumpheap -mt <addr> # every instance of a specific type
> gcroot <addr> # why is this object still referenced?
gcroot is the command that actually answers the question that matters — not just what's using memory but what's holding a reference to it and refusing to let it go. A static event handler that never unsubscribes, or a cache with no eviction policy, are the usual suspects it turns up.
How this fits with OpenTelemetry
If you've already wired up distributed tracing with OpenTelemetry, these tools aren't a replacement — they're what you reach for when the exported traces and metrics tell you which service is misbehaving, and you need to go one level deeper into why, on that specific process, right now.