Skip to content

.Net Diagnostic Tools for Probing Your Application

Last week we explored tools like Serilog and OpenTelemetry to see what goes on in our applications. But we can only add these tools if we can modify our application. If this is not an option, we need another approach.

Thanks to the .Net diagnostics tools that Microsoft build around .Net, we can peak into our applications without modifying any code. That makes these tools a great help in any situation, even if we could change the application. Let us dive right into them.

The common part of the diagnostic tools

While the .Net diagnostic tools are a collection of different tools, they have a lot in common. We can install the diagnostic tools as a global .Net tool with this command (just replace dotnet-dump with the name of the tool you want to use):

dotnet tool install --global dotnet-dump

To find the application we want to work with, we can use the option ps to list the running .Net applications:

1
2
3
4
dotnet-dump ps

7276   Seq         [Elevated process - cannot determine path]
44412  WebApp      Testing_Net9\WebApp\bin\Debug\net9.0\WebApp.exe

This output shows us the two identifiers we can use with most tools: the process id and the name of the application. To use the name to identify the app we want to analyse, we can use the -n NAME option (or --name NAME):

dotnet-dump collect -n WebApp

If we use the name, we can reuse the same command even when we restart the application in between. The process Id will change whenever we start the application, but when we have two applications with the same name, we need to use the -p option with the id to tell the diagnostic tool what app we are interested in:

dotnet-dump collect -p 44412

dotnet-counters shows performance counters

The dotnet-counters tool displays real-time performance metrics for .NET applications using the EventCounter API. It monitors CPU usage, garbage collection, exception rates, and more, making it ideal for initial health checks and quick performance investigations.

It takes a bit time to get used to the syntax to observe the right counters. Skip the general part of the documentation and dive right into the examples – that will help you better. We can get the list of the well-known counters with the list option:

dotnet-counters list

To get the periodically refreshing values of the selected counters, we can use the monitor option in combination with a list of counters we are interested in:

dotnet-counters monitor -n WebApp --counters System.Runtime

If we want to be more specific with the counter selection, we can modify the --counters option to this:

--counters System.Runtime[cpu-usage,gc-heap-size],System.Net.Security[total-tls-handshakes]

If we want to save the result, we can use the collect option and reuse the counter selection from above:

dotnet-counters collect -n WebApp --counters System.Runtime

This creates a CSV file with an entry for each counter every second. If we prefer JSON, we can set it with the --format json parameter:

dotnet-counters collect --format json -n WebApp --counters System.Runtime

dotnet-trace tracks time spend in methods

The dotnet-trace tool allows us to create a trace file that registers how long each of our methods took and how often it was called. This helps us to figure out what parts of the application are important and if they are slow or not.

We can create a trace file for our application with this command:

dotnet-trace collect -n WebApp

When the performance test run is done, we can stop recording and the output ends in a *.nettrace file.

To get a quick overview of the top 6 methods, we can use this command:

dotnet-trace report WebApp.exe_20250129_215735.nettrace topN -n 6


Top 6 Functions (Exclusive)                                                  Inclusive           Exclusive
1. WaitHandle.WaitMultiple(value class System.ReadOnlySpan`1<class System    30.77%              30.77%
2. WaitHandle.WaitOneNoCheck(int32,bool,class System.Object,value class W    22.52%              22.52%
3. PortableThreadPool+IOCompletionPoller.Poll()                              15.38%              15.38%
4. Monitor.Wait(class System.Object,int32)                                   7.69%               7.69%
5. WaitHandle.WaitAnyMultiple(value class System.ReadOnlySpan`1<class Mic    7.69%               7.69%
6. LowLevelLifoSemaphore.WaitForSignal(int32)                                7.67%               7.67%

To see more details, we can open the trace file in Visual Studio or in PerfView. I find the user interface of PerfView an enormous pain and did not bother to dig deeper. If you want to learn more, you can find a good series of tutorials on learn.microsoft.com.

Visual Studio is a much nicer option to dive through the trace file. We will cover this in a dedicated post on how to find the hot path in our application.

dotnet-dump creates a memory dump

We can create a dump file containing the whole memory of our application with the dotnet-dump command:

1
2
3
4
5
6
dotnet-dump collect -n WebApp


WebApp
Writing full to c:\demo\dump_20250129_220345.dmp
Complete

We can use the analyze option to start digging into the dump file. Make sure that you check the documentation on the available commands. The user interface is more or less WinDbg and archaic.

A much simpler and more understandable approach is to use a tool like JetBrains dotMemory that I will cover in a few weeks in a dedicated post as well.

dotnet-gcdump dumps the heap

If we care only about the heap part of the memory, we can create the dump file with dotnet-gcdump:

1
2
3
4
5
dotnet-gcdump collect -n WebApp


Writing gcdump to 'c:\demo\20250129_221015_44412.gcdump'...
        Finished writing 2069216 bytes.

We can use the report option to dive into the dump file:

dotnet-gcdump report 20250129_221015_44412.gcdump

This will flood your terminal with data, and it takes its time to understand the important parts of the output.

dotnet-stack shows the stack

If we are only interested in the stack part of the memory, we can use the dotnet-stack command to get the stack trace of each process inside our application:

dotnet-stack report -n WebApp


Thread (0x90D8):
  [Native Frames]
  System.Private.CoreLib.il!System.Threading.WaitHandle.WaitOneNoCheck(int32,bool,class System.Object,value class WaitHandleWaitSourceMap)
  System.Private.CoreLib.il!System.Threading.PortableThreadPool+GateThread.GateThreadStart()

Thread (0x9CC8):
  [Native Frames]
  System.Private.CoreLib.il!System.Threading.PortableThreadPool+IOCompletionPoller.Poll()

Thread (0xDD34):
  [Native Frames]
  System.Private.CoreLib.il!System.Threading.PortableThreadPool+IOCompletionPoller.Poll()

Next

With the .Net diagnostic tools we can gain insights into our application without making any changes to it. The tools are a great help to learn more about the running application, but they are not the most user-friendly tools. Later in this series we will use Visual Studio and JetBrains dotMemory to analyse the collected data. Those tools offer a better user experience and require less knowledge to spot problems.

Next week we take a detour and explore our options to get more test data into our application – that will help us spot data related problems much faster.