Mert Özen Further With Every Line
Developer Tools Profiling and Diagnostics in .NET — #10

What Changed in Diagnostics from .NET 8 to .NET 11: The System.Runtime Meter, New dotnet-trace Profiles, collect-linux, Runtime Async Stacks and In-Process Crash Reports

Mert Özen Nov 14, 2026 11 min 1 views
What Changed in Diagnostics from .NET 8 to .NET 11: The System.Runtime Meter, New dotnet-trace Profiles, collect-linux, Runtime Async Stacks and In-Process Crash Reports

.NET 9 replaced the old EventCounters with the System.Runtime Meter, .NET 10 brought new dotnet-trace defaults and collect-linux, and .NET 11 added lossless event buffering, runtime async and in-process crash reports. We give a mapping table from old to new metric names and show, with real runs on .NET 10 and 11, the cpu-sampling and Block mode errors, runtime async stacks before and after a suspension, and crash report output.

Version note: The code in this post was built and run with .NET 10 (runtime 10.0.15 in the container) and .NET 11 (SDK 11.0.100, runtime 11.0.0), with dotnet-trace, dotnet-counters and dotnet-gcdump 10.0.750501.

Guess First

Bookstore, the online bookstore used as the shared example across this site's series, is moving to .NET 11, and you read in the release notes that runtime async simplifies stack traces. Checkout has a three-level async chain: CheckoutAsync awaits PriceAsync, which awaits LoadCampaignRulesAsync. After the innermost method awaits a Task.Delay and resumes on the thread pool, does a profiler or new StackTrace() looking at the stack see PriceAsync and CheckoutAsync? Does the answer change with runtime async on and off? The answer is in the two outputs of the runtime async section.

At a Glance: From .NET 8 to .NET 11

A good part of the tools and runtime features we used in this series arrived or changed in the last three releases. When you read an old blog post, runbook or dashboard, you need to know which belongs to which release:

ReleaseChangeWhere in the series
.NET 8ASP.NET Core and Kestrel metrics are Meter-based (http.server.request.duration)02
.NET 9The System.Runtime Meter; dotnet.* metrics replacing the old EventCounters02, this post
dotnet-counters 9.0.652701Connecting to iOS/Android apps with --dsrouterThis post
.NET 10 and tools 10.xDefault profiles dotnet-common + dotnet-sampled-thread-time; cpu-sampling removed from collect; collect-linux and --probe; install-free runs with dnx; the database profile01, 03, 07
.NET 10 runtimeThe AllocationSampled event (randomized allocation sampling)04, 06
.NET 11Lossless (Block) event buffering; runtime async and simplified live stacks; async profiler events unified into one model; in-process crash reports on Linux and macOS; removal of DOTNET_RuntimeAsyncThis post
dotnet-monitor 10.0.5Major roll-forward to the .NET 11 runtime08

The .NET 11 part follows the "What's new in .NET 11 runtime" page updated for the GA release of November 10, 2026; below I tried the claims on that page one by one.

.NET 8 and 9: Metrics Move to Meters

In 02 we saw that on .NET 8 and earlier dotnet-counters falls back to the old System.Runtime EventCounters, and we left for this post which old name maps to which new metric. Microsoft Learn keeps the two lists on separate pages and gives no one-to-one mapping table. I built the table below by comparing the definitions on the two pages (which .NET API value each one reports):

Old EventCounter (.NET 8 and earlier)New metric (.NET 9+)Difference
cpu-usagedotnet.process.cpu.timeSeconds instead of percent, user/system split through cpu.mode
time-in-gc, total-pause-time-by-gcdotnet.gc.pause.timeTotal pause time instead of a percent relative to the last GC; you compute the ratio
alloc-ratedotnet.gc.heap.total_allocatedCumulative bytes, not per interval
gen-0-gc-count, gen-1-gc-count, gen-2-gc-countdotnet.gc.collectionsOne metric, with a gc.heap.generation attribute
gen-0-size ... loh-size, poh-sizedotnet.gc.last_collection.heap.sizePer generation, through an attribute
gc-fragmentationdotnet.gc.last_collection.heap.fragmentation.sizeBytes instead of percent
gc-committeddotnet.gc.last_collection.memory.committed_size
working-setdotnet.process.memory.working_setBytes instead of MB
exception-countdotnet.exceptionsException type as an attribute
monitor-lock-contention-countdotnet.monitor.lock_contentions
threadpool-thread-count, threadpool-queue-length, threadpool-completed-items-countdotnet.thread_pool.thread.count, dotnet.thread_pool.queue.length, dotnet.thread_pool.work_item.count
active-timer-count, assembly-countdotnet.timer.count, dotnet.assembly.count
methods-jitted-count, il-bytes-jitteddotnet.jit.compiled_methods, dotnet.jit.compiled_il.sizeNew: dotnet.jit.compilation.time

Some have no counterpart: gc-heap-size (GC.GetTotalMemory) and gen-0-gc-budget have no direct equivalent in the new list. When you move your dashboard, watch the unit changes; connect an old "percent" chart to the new metric and the numbers arrive as seconds or bytes, and you have to compute the ratio in the query. There is also the --dsrouter option that came with dotnet-counters 9.0.652701: it starts dotnet-dsrouter, which routes the diagnostic port of mobile apps to a TCP connection, and connects to it, so .NET apps on iOS and Android can be monitored with the same tool.

.NET 10: dotnet-trace's New Defaults, collect-linux and dnx

The change we covered in detail in 03 is where people following old guides trip first: the cpu-sampling profile was removed from collect. Running the command from an old guide in the .NET 10 lab:

docker exec profseries-api dotnet-trace collect -p 1 --profile cpu-sampling --duration 00:00:00:02
[ERROR] The specified profile 'cpu-sampling' does not apply to `dotnet-trace collect`.

The equivalent is --profile dotnet-sampled-thread-time,dotnet-common; if you pass no profile, those two are on anyway. The name cpu-sampling now exists only in collect-linux, where it means the kernel's real CPU sampling. Do not forget the idea behind this rename (what is sampled is wall-clock time, not CPU); the "CPU profile" screenshot in an old guide was actually counting waiting threads too.

The other three things that came in the same release were used in other posts of the series: collect-linux brings native frames and all processes into one trace through the kernel's user_events facility (kernel 6.4+ and CONFIG_USER_EVENTS required; that is why it did not work in our lab environment, 03), dnx runs tools once without installing them (01), and the database profile puts EF Core and ADO.NET commands into the trace (07). On the runtime side, the AllocationSampled event that came with .NET 10 fixes the error of the old AllocationTick's fixed-step sampling (04).

.NET 11: Lossless Event Buffering

When EventPipe's buffer fills, the default behavior is to drop events; the app does not slow down, but the trace is incomplete. dotnet-trace 10.0.750501 brought the --buffering-mode Block option that changes this: while the buffer is full, threads that emit events wait until there is room. According to the docs this mode requires the .NET 11 runtime and can slow the app down. I tried it first in the .NET 10 lab:

docker exec profseries-api dotnet-trace collect -p 1 --buffering-mode Block --duration 00:00:00:02 -o /tmp/b.nettrace
The runtime version being traced does not support the 'Block' buffering mode, which requires .NET 11+. Retry with --buffering-mode Drop or omit --buffering-mode.

To run the same app on the .NET 11 runtime without recompiling it, I copied the output of the image from 09 into the .NET 11 ASP.NET Core image and turned on major roll-forward:

FROM profseries-api:lab09 AS lab

FROM mcr.microsoft.com/dotnet/aspnet:11.0
WORKDIR /app
COPY --from=lab /app .
COPY --from=lab /tools /tools
ENV PATH="$PATH:/tools" DOTNET_ROLL_FORWARD=Major
USER $APP_UID
ENTRYPOINT ["dotnet", "Bookstore.Api.dll"]

On the first try I wrote the environment variable as DOTNET_RollForward, and the app refused to start with "You must install or update .NET to run this application"; the correct name is DOTNET_ROLL_FORWARD. In the fixed image the same command took a trace without problems. dotnet-gcdump uses Block mode on its own as well: the "the target runtime does not support non-lossy (Block) buffering" warning we saw with every gcdump on .NET 10 in 05 and 09 did not appear on .NET 11:

docker exec profseries-api dotnet-gcdump collect -p 1 -o /tmp/x.gcdump
Writing gcdump to '/tmp/x.gcdump'...
	Finished writing 812867 bytes.

In practice: a gcdump of a large heap could come out incomplete on .NET 10; on .NET 11 it does not. In dotnet-trace, turn on Block mode only when a complete trace matters more than the app slowing down.

.NET 11: Runtime Async and Simpler Stacks

The biggest runtime change in .NET 11 is runtime async: the runtime itself suspends and resumes async methods instead of compiler-generated state machines. .NET's own libraries ship compiled in this mode; for your own code you turn it on in a project targeting net11.0:

<PropertyGroup>
  <TargetFramework>net11.0</TargetFramework>
  <Features>runtime-async=on</Features>
</PropertyGroup>

The DOTNET_RuntimeAsync environment variable from the preview period was removed; to turn it off you use <UseRuntimeAsync>false</UseRuntimeAsync> in the project. I tried the claim from the docs with a small program that imitates Bookstore's checkout chain. Because the rules are cached, the first await completes synchronously, and then a real suspension follows:

using System.Diagnostics;

// checkout -> pricing -> campaign rules: three async layers, like Bookstore's checkout
await CheckoutAsync(crash: args.FirstOrDefault() == "crash");

static async Task CheckoutAsync(bool crash)
{
    decimal total = await PriceAsync(crash);
    Console.WriteLine($"total {total}");
}

static async Task<decimal> PriceAsync(bool crash)
{
    string[] rules = await LoadCampaignRulesAsync(crash);
    return 100m - rules.Length;
}

static async Task<string[]> LoadCampaignRulesAsync(bool crash)
{
    // rules already cached: the await completes synchronously
    string[] rules = await ValueTask.FromResult<string[]>(["BOOK25", "BOOK50"]);
    Console.WriteLine("-- before a real suspension");
    Console.WriteLine(new StackTrace());

    await Task.Delay(10);
    Console.WriteLine("-- after resuming on the thread pool");
    Console.WriteLine(new StackTrace());

    if (crash)
        throw new InvalidOperationException("Campaign rules could not be loaded");
    return rules;
}

With the .NET 11 SDK I built and ran it once with the feature on and once off with -p:Features=. The stack before the suspension, with runtime async on:

   at Program.<<Main>$>g__LoadCampaignRulesAsync|0_2(Boolean crash)
   at Program.<<Main>$>g__PriceAsync|0_1(Boolean crash)
   at Program.<<Main>$>g__CheckoutAsync|0_0(Boolean crash)
   at Program.<Main>$(String[] args)
   at Program.<Main>(String[] args)

With it off, the same point is 13 frames: for every async method AsyncMethodBuilderCore.Start appears and the method itself shows up twice. The docs' comparison of 13 against 5 matched exactly. The more instructive output is the second one, the stack that resumes on the thread pool after Task.Delay. With runtime async on:

   at Program.<<Main>$>g__LoadCampaignRulesAsync|0_2(Boolean crash)
   at System.Threading.Tasks.Task.RunContinuations(Object continuationObject)
   at System.Threading.Tasks.Task.TrySetResult()
   at System.Threading.Tasks.Task.DelayPromise.CompleteTimedOut()
   at System.Threading.TimerQueueTimer.Fire(Boolean isThreadPool)
   at System.Threading.ThreadPoolWorkQueue.Dispatch()
   at System.Threading.PortableThreadPool.WorkerThread.WorkerDoWork(PortableThreadPool threadPoolInstance)
   at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
   at System.Threading.Thread.StartCallback(Thread* pThread)

The answer to the guess: no, it does not see them in either mode. After a real suspension, PriceAsync and CheckoutAsync are no longer on the stack; they are continuations waiting to be resumed. With runtime async off, the same stack is 15 frames with AsyncStateMachineBox, ExecutionContext.RunInternal and AwaitTaskContinuation in between; with it on, 9 frames. So runtime async reduces the infrastructure frames in between but does not answer "who called this code" beyond the async boundary. The practical consequence for profiling: in the flame graphs from 03 you will see short stacks starting at the thread pool, and you will have to find the calling endpoint not from the stack but from activities tied to the request. Exception stack traces, as the docs also say, are already the same in both modes; ExceptionDispatchInfo collects the chain. On the debugger side, breakpoints bind correctly in runtime async methods and you can step across await boundaries without jumping into compiler infrastructure. I did not try this with an IDE on this machine; it is taken from the release notes. According to the same notes, async profiler events now cover both runtime async and state machine methods with one model; tools no longer have to handle the two paths separately.

.NET 11: In-Process Crash Reports

Until now, crash diagnosis on Linux and macOS relied on createdump running outside the process. .NET 11 brought an in-process reporter from the mobile platforms to these systems: before the process dies it writes the managed stack, the module list and part of the runtime state. With minidumps off, you turn it on with DOTNET_EnableCrashReport=1 or DOTNET_EnableCrashReportOnly=1. I ran the program above with the crash argument in a .NET 11 SDK container:

DOTNET_EnableCrashReport=1 dotnet /tmp/on/Bookstore.AsyncStacks.dll crash
Unhandled exception. System.InvalidOperationException: Campaign rules could not be loaded
   at Program.<<Main>$>g__LoadCampaignRulesAsync|0_2(Boolean crash) in /src/Bookstore.AsyncStacks/Program.cs:line 30
   at Program.<<Main>$>g__PriceAsync|0_1(Boolean crash) in /src/Bookstore.AsyncStacks/Program.cs:line 14
   at Program.<<Main>$>g__CheckoutAsync|0_0(Boolean crash) in /src/Bookstore.AsyncStacks/Program.cs:line 8
   at Program.<Main>$(String[] args) in /src/Bookstore.AsyncStacks/Program.cs:line 4
   at Program.<Main>(String[] args)
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
.NET Crash Report v1.0.0
Build: 11.0.26.53018 @Commit: 297acf930fb0f5c07fd4e51d78e521b4ad2e5d1f
Cmdline: dotnet
pid: 69
signal 6 (SIGABRT)

--- thread 0x45 (crashed) ---
  managed exception: System.InvalidOperationException (0x80131509)
  #00 [0] System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw + 0x11 (token=0x6007dad)
  #01 [0] System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess + 0x37 (token=0x60081a7)
  #02 [0] System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification + 0x33 (token=0x60081a6)
  #03 [1] Program.<Main> + 0xb (token=0x6000003)
  #04 [0] System.Environment.CallEntryPoint + 0x1c (token=0x60004a8)

--- thread 0x4d ---
  #00 [0] ThreadWaitInfo.Wait + 0x46 (token=0x6003ea6)
  ...

modules:
  [0] System.Private.CoreLib {5bfafa8d-0165-4b17-8d55-9999fdc89f94}
  [1] Bookstore.AsyncStacks {b3354c5b-4c98-4012-adcb-3784260e423e}

(Output trimmed.) In this run the report went to standard error (stderr) and no separate file was created; in a container that means the report lands in kubectl logs. Two details stand out. The report gives the managed frames of each thread with a metadata token and offset; there are no line numbers, but with the GUIDs in the module list you can find the right PDB and resolve them. The crashed thread's stack at the moment of the crash only goes up to Main: because the exception travels up the async chain and is rethrown at the top, the real chain is in the stack trace of the exception message. Without the variable, the same crash produced only the 7-line exception output. This does not replace a dump (no memory, no heap); but where taking a dump is expensive or forbidden, it records the threads at the moment of the crash for free. The dump side is covered in the Debugging and Problem Solving series post "Analyzing Crash and Memory Dumps: dotnet-dump, SOS Commands and Taking Dumps in Containers".

Reading Old Guides

Most .NET diagnostics content on the internet was written between .NET Core 3.1 and .NET 6. Before applying a guide I check these four things:

  • PerfCollect: a bash script that uses LTTng and perf on Linux. It is still documented on Microsoft Learn, but on .NET 10 there is dotnet-trace collect-linux for the same need (native frames, kernel events); if you only need the managed side, plain collect is enough.
  • The cpu-sampling profile and the phrase "CPU profile": it now fails in collect; keep in mind that what is sampled is wall-clock time.
  • Old EventCounter names ("% Time in GC", "CPU Usage", gen-2-gc-count): not published by .NET 9+ apps; translate them to the new names with the table above.
  • The "profiling is Windows-only" assumption: Visual Studio's CPU Usage, allocation, async, counters, Events Viewer and Database tools also work with a Linux and WSL target (07), TraceEvent runs on Linux (06), and dotnet-monitor runs everywhere (08).

In Production

Moving to .NET 11 also means checking tool versions. The dotnet-trace, dotnet-counters and dotnet-gcdump 10.0.750501 we used in this series attached to the .NET 11 runtime with major roll-forward; dotnet-monitor supports .NET 11 from version 10.0.5. But new features such as Block mode need both the new tool and the new runtime; update the tool version in your images together with the runtime. Account for live stacks changing once you turn on runtime async: if you have a custom profiling script, a log enricher or an alert rule that depends on stack depth or specific frame names, try it again on .NET 11. When moving dashboards to the System.Runtime Meter, keep the old and new names in parallel for a while; because units changed, charts can produce false alarms from day one.

If you decide to turn on in-process crash reports, think about where the report goes: if it goes to stderr, your log system may split it line by line and your log retention rules apply to it too. It contains no secrets, but it does contain the command line, the module list and stacks.

Common Pitfalls

  • Trying --buffering-mode Block on .NET 10; if the runtime is not .NET 11 the tool reports an error and stops.
  • Thinking runtime async brings the calling methods back onto the stack in a suspended chain.
  • Thinking DOTNET_RuntimeAsync is still a setting in .NET 11 GA; it was removed, the project property is used instead.
  • Writing DOTNET_RollForward for roll-forward; the environment variable is named DOTNET_ROLL_FORWARD.
  • Leaving the dotnet-trace collect --profile cpu-sampling command from an old guide in a CI script.
  • Using the in-process crash report in place of a dump; it contains no memory and no heap.

A Small Experiment

★☆☆ List the old EventCounter names in your dashboard or runbook and translate each to the new metric with the table above; mark the ones whose unit changed.

★★☆ Build Bookstore.AsyncStacks with runtime async on and off and take a 10-second trace with dotnet-trace; with the cpu command of Bookstore.TraceStats from 06, compare the average stack depth and the share of AsyncStateMachineBox frames in the two traces.

★★★ Repeat the checkout case from 09 with the .NET 11 roll-forward image: under the same load, are p99, allocation and GC pause different from .NET 10? Only the runtime changed; the code is the same. If there is a difference, write down with which tool and where you saw it, using the report template from 09.

This post ends the series. Starting from the map (01) we covered metrics, CPU and allocation profiles, heap snapshots, PerfView, IDE tools and automatic collection in production, then used them all in order on a single case. The one habit you should keep: before and after a performance change, measure under the same load.