Programmatic .gcdump, .dmp and .nettrace capture for .NET applications.
Memory.Introspect is a lightweight C# library that wraps the functionality of the official dotnet-gcdump, dotnet-dump and dotnet-trace tools. It allows developers to capture garbage collection (GC) dumps, process dumps and EventPipe traces directly from within their code, without needing to shell out to a CLI or manage external processes.
Normally, capturing a .gcdump requires running the dotnet-gcdump command-line tool against a Process ID (PID). While effective for ad-hoc debugging, it is difficult to automate within an application.
Memory.Introspect allows you to:
- Self-Monitor: Have an application trigger its own memory dump to analyze memory leaks.
- Automate: Integrate memory capturing into integration tests or CI/CD pipelines.
- Streamline: Avoid parsing CLI text output; work with strong types and direct boolean results.
Memory.Introspect is available as a NuGet package
dotnet add package Memory.IntrospectThe library exposes a simple CollectMemoryGraphAsync method that connects to the target process via the .NET Diagnostics Client (EventPipe).
Here is how to capture the current process's memory graph and save it to a temporary file:
using System.Diagnostics;
using Memory.Introspect;
using Microsoft.Extensions.Logging;
int currentPid = Process.GetCurrentProcess().Id;
var loggerFactory = LoggerFactory.Create(f => f.AddConsole());
var logger = loggerFactory.CreateLogger("Memory.Introspect");
logger.LogInformation("Starting creating gcdump file from process {0}", currentPid);
var result = await MemoryIntrospector.Create(new() { Logger = logger, Verbose = true }).CollectMemoryGraphAsync(currentPid);
if (result.Success)
{
var gcDumpFile = $"{DateTimeOffset.UtcNow:yyyy-MM-dd-HH-mm-ss}-process-{currentPid}.gcdump";
logger.LogInformation("Writing .gcdump file to {0}", gcDumpFile);
result.SaveToDisk(gcDumpFile);
}
logger.LogInformation("Finished creating gcdump file");CollectTraceAsync is the programmatic equivalent of dotnet-trace collect. Streaming
straight to a file is what you want for anything non-trivial — the trace never has to fit in
memory:
var introspector = MemoryIntrospector.Create(new() { Logger = logger });
var trace = await introspector.CollectTraceAsync(pid, new TraceCollectionOptions
{
Duration = TimeSpan.FromSeconds(30),
OutputPath = "app.nettrace",
Progress = new Progress<TraceProgress>(p => logger.LogInformation("{0}", p)),
});
// dotnet-trace report topN
foreach (var m in trace.TopMethods(count: 10))
{
logger.LogInformation("{0,6:0.00}% {1}", m.ExclusiveMetricPercent, m.Name);
}Omit OutputPath to buffer the trace in memory instead and read it back from
trace.NetTraceData.
// dotnet-trace list-profiles
foreach (var profile in MemoryIntrospector.ListTraceProfiles())
{
Console.WriteLine($"{profile.Name}: {profile.Description}");
}
var trace = await introspector.CollectTraceAsync(pid, new TraceCollectionOptions
{
Duration = TimeSpan.FromSeconds(20),
// --profile gc-collect (combine several with |)
Profiles = TraceProfileKind.GcCollect,
// --providers "MyCompany-MyApp:0xF:5"
Providers = new[] { "MyCompany-MyApp:0xF:5" },
// --clrevents gc+exception --clreventlevel verbose
ClrEvents = ClrEventKeywords.Gc | ClrEventKeywords.Exception,
ClrEventLevel = EventLevel.Verbose,
OutputPath = "app.nettrace",
});Profiles and CLR event keywords are strongly typed: TraceProfileKind and ClrEventKeywords
are [Flags] enums, and ClrEventLevel is System.Diagnostics.Tracing.EventLevel. When the
values come from configuration or a command line instead of from code,
ProviderUtils.ParseClrEvents("gc+exception"), ProviderUtils.ParseEventLevel("verbose") and
TraceProfiles.Find("gc-collect") map the CLI spellings onto them.
When no profile, provider or CLR event is configured at all, the same defaults as the CLI tool
are used: TraceProfileKind.Default (dotnet-common + dotnet-sampled-thread-time).
var trace = await introspector.CollectTraceAsync(pid, new TraceCollectionOptions
{
Duration = TimeSpan.FromMinutes(5), // upper bound
Providers = new[] { "MyCompany-MyApp:0x0:4" },
StoppingEventProviderName = "MyCompany-MyApp",
StoppingEventEventName = "RequestFailed",
StoppingEventPayloadFilter = new Dictionary<string, string> { ["statusCode"] = "500" },
OutputPath = "failure.nettrace",
});
if (trace.StoppedByStoppingEvent) { /* the trace was cut short by the event */ }// dotnet-trace collect --format speedscope
var trace = await introspector.CollectTraceAsync(pid, new TraceCollectionOptions
{
Duration = TimeSpan.FromSeconds(20),
OutputPath = "app.nettrace",
Format = TraceFileFormat.Speedscope, // trace.ConvertedFilePath
});
// dotnet-trace convert --format chromium
string chromium = introspector.ConvertTraceFile("app.nettrace", TraceFileFormat.Chromium);
// dotnet-trace report topN, on any .nettrace file
var top = introspector.ReportTopMethods("app.nettrace", count: 10);
// dotnet-trace ps
IReadOnlyList<int> pids = MemoryIntrospector.GetTraceableProcesses();CollectAllocationReportAsync traces the CLR's allocation sampling events and reports what a
process allocated over the interval, by type:
var report = await introspector.CollectAllocationReportAsync(pid, TimeSpan.FromSeconds(10), count: 10);
AllocationTracing.Write(Console.Out, report);Top 3 Allocated Types of 3 (16.66 GiB total, 167,438 AllocationTick events)
Type Bytes % LOH Objects
1. System.Byte[] 16.65 GiB 99.99% - -
2. System.InvalidOperationException 1.73 MiB 0.01% - -
3. System.GCMemoryInfoData 104.29 KiB 0% - -
Each AllocatedType carries AllocatedBytes, AllocatedBytesPercent, SampleCount and the
SmallObjectHeapBytes / LargeObjectHeapBytes split — a type showing bytes in the LOH column
is allocating objects past the 85,000 byte threshold.
To keep the underlying .nettrace, or to run the report over a file you already have:
// keep the trace as well as the report
var report = await introspector.CollectAllocationReportAsync(pid, TimeSpan.FromSeconds(10),
outputPath: "allocations.nettrace");
// or drive the capture yourself and report afterwards
var trace = await introspector.CollectTraceAsync(pid, AllocationTracing.CreateOptions(TimeSpan.FromSeconds(10), "allocations.nettrace"));
var report = trace.TopAllocatedTypes(count: 20);
trace.WriteAllocationReport(Console.Out, count: 20);
// or analyse a .nettrace captured earlier
var offline = introspector.ReportTopAllocatedTypes("allocations.nettrace", count: 20);A few things worth knowing:
- The numbers come from
GCAllocationTick, which the runtime emits once per ~100 KB allocated. That makes allocated bytes per type accurate, but there are no per-object counts —ObjectCountstays 0 unless the runtime emitted the per-objectGCSampledObjectAllocationevents instead. - Allocation tracing is verbose. An allocation-heavy process can produce tens of MB of events
per second, so prefer short intervals and raise
CircularBufferSizeInMBif events drop. - A trace captured without
AllocationTracing.RequiredClrEventsreportsAllocationReport.IsEmptyrather than throwing.
EventPipe already records a call stack for every allocation event. What it cannot do without rundown is give you the method names to resolve those stacks against — so asking for call stacks turns rundown on:
var report = await introspector.CollectAllocationReportAsync(
pid, TimeSpan.FromSeconds(10), count: 10, outputPath: null, resolveCallStacks: true);
AllocationTracing.WriteCallStacks(Console.Out, report);Top 3 Allocating Call Stacks
1. 44.35 GiB (100%) System.Byte[]
Workload.AllocateGarbage(CancellationToken)
<- Workload+<>c__DisplayClass0_0.<RunAsync>b__3()
<- ExecutionContext.RunFromThreadPoolDispatchLoop(...)
<- Task.ExecuteWithThreadLocal(...)
<- ThreadPoolWorkQueue.Dispatch()
Each AllocationCallStack carries AllocatedBytes, AllocatedBytesPercent, SampleCount, the
dominant TypeName allocated through it, and Frames (allocating method first). Stacks are
aggregated by their full frame list, so two different paths into the same allocating method stay
separate.
The same flag works on the lower-level entry points:
// drive the capture yourself
var trace = await introspector.CollectTraceAsync(pid,
AllocationTracing.CreateOptions(TimeSpan.FromSeconds(10), "alloc.nettrace", resolveCallStacks: true));
var report = trace.TopAllocatedTypes(count: 10, resolveCallStacks: true);
// or analyse a file captured earlier (its frames only resolve if it was captured with rundown)
var offline = introspector.ReportTopAllocatedTypes("alloc.nettrace", count: 10, resolveCallStacks: true);It costs more at both ends, which is why it is opt-in:
- Capture — rundown makes stopping the session slower and the trace larger.
- Analysis — the per-type tally streams the trace and is stack-blind; resolving stacks
needs
TraceLog, which converts the trace to ETLX first. That is slow on a large capture.
Prefer short durations. AllocationReport.HasCallStacks tells you whether a given report
carries them; asking a rundown-less trace for stacks yields unnamed ? frames rather than an
error.
StartGcPauseMonitorAsync keeps a cheap EventPipe session open (GC events at Informational
level, no stacks, no rundown) and explains every pause the moment it ends: how long threads took
to stop, which GC ran and why, how long mark / plan / relocate / compact took, and which kind of
root the marking went to. When a pause crosses the threshold it can escalate into a short
detailed capture that records who triggered the GC (call stacks), what was being allocated and
pinned, and optionally which types fill the heap.
await using var monitor = await introspector.StartGcPauseMonitorAsync(Environment.ProcessId, new GcPauseMonitorOptions
{
LongPauseThreshold = TimeSpan.FromMilliseconds(500),
OnLongPause = pause => logger.LogWarning("{0}", pause.ToReport()),
DetailedCapture = new() { OnReport = report => logger.LogWarning("{0}", report.ToReport()) },
});8,027 ms pause: GC #61 gen2 NonConcurrentGC reason=InducedCompacting compacting+LOH -> cause: Compaction
phases: mark 1,849 ms (...), plan 195 ms, relocate 5,241 ms, compact 741 ms
mark by root: overflow 1,042 ms/67.7 MB, stack 806 ms/833.1 MB, handles 0.8 ms/2.0 MB
* The GC was induced (InducedCompacting): something called GC.Collect asking for a compacting collection ...
* The mark stack overflowed: 1,042 ms went into recovering from it ...
Memory.Introspect.GcPauseRepro reproduces multi-second
pauses (millions of interlinked objects plus a forced compacting GC.Collect on a timer) and
checks every stall a heartbeat thread notices against what the monitor reported; its README has
the measurements.
Every capture works against the current process — pass Environment.ProcessId and the library
connects to its own diagnostics endpoint:
var report = await introspector.CollectAllocationReportAsync(Environment.ProcessId, TimeSpan.FromSeconds(10));The tracing machinery does allocate a little while it runs (mostly the
StreamCopyBufferSizeInBytes pump buffer, which lands on the LOH at its 1 MB default), so it
shows up in its own report. Measured against an otherwise idle process it came to ~1.7 MiB over
6 seconds — around 0.03% of the same self-trace with a real workload running. Use
SamplingExcludedModules if you want the library's own frames kept out of method reports.
Both the runtime-side circular buffer and the client-side stream buffer are exposed, the same
way they are for .gcdump capture, so a big or very chatty process does not silently drop
events:
var trace = await introspector.CollectTraceAsync(pid, new TraceCollectionOptions
{
Duration = TimeSpan.FromSeconds(60),
// The runtime's in-memory circular buffer. Raise it when events are being dropped.
// Defaults to MemoryIntrospectorOptions.CircularBufferSizeInMB (1024 MB).
CircularBufferSizeInMB = 4096,
// The buffer used to pump the EventPipe stream out to disk.
StreamCopyBufferSizeInBytes = 32 * 1024 * 1024,
// Rundown resolves jitted method names, but on a huge app it dominates both the
// stop time and the file size — turn it off when you don't need symbolised stacks.
Rundown = false,
// Recording a stack for every event is the expensive part of event collection
// (requires .NET 9+ on the target).
RequestStackwalk = false,
OutputPath = "huge-app.nettrace",
});The resulting .gcdump and .nettrace files can be opened in:
- Visual Studio
- PerfView
.nettrace files converted with TraceFileFormat.Speedscope open in
speedscope.app; TraceFileFormat.Chromium output opens in
chrome://tracing and Perfetto.
When initializing the Memory.Introspect, you can pass a configuration object:
| Option | Type | Description |
|---|---|---|
Logger |
ILogger |
Used to log the internal diagnostics protocol progress (Handshake, EventPipe setup, etc.). |
Verbose |
bool |
If true, outputs detailed logs regarding the connection status and graph construction. |
Timeout |
TimeSpan |
(Optional) Set a maximum duration for the collection process before cancelling. Minimum of 30s. |
CircularBufferSizeInMB |
int |
The runtime's in-memory circular buffer, in MB (default 1024). Used by .gcdump, sampling and trace capture, and overridable per trace through TraceCollectionOptions.CircularBufferSizeInMB. |
DiagnosticPort |
string |
(Optional) Connect through a diagnostic port instead of a process id. |
The package ships a Claude skill that documents how to use this library — every capture type,
its options, and worked examples. If the project referencing Memory.Introspect has a
.claude folder anywhere at or above it, building that project extracts the skill into
.claude/skills/memory-introspect/:
.claude/skills/memory-introspect/
SKILL.md # overview, capture-type decision table, reference index
references/
getting-started.md trace-collect.md profiles.md
providers-and-clrevents.md custom-eventsource.md stopping-events.md
cpu-sampling.md allocation-tracing.md allocation-call-stacks.md
gc-dump.md process-dump.md formats-and-conversion.md
offline-reports.md self-tracing.md large-processes.md
diagnostic-ports.md diagnostics-endpoint.md ci-and-tests.md
gc-pauses.md api-reference.md troubleshooting.md
There is nothing to configure. No .claude folder means the extraction does nothing, and the
files are re-copied only when the package version changes — so an upgrade refreshes the
documentation automatically.
The plumbing is two files:
Memory.Introspect/skills/is the skill payload, packed into the NuGet package'sskills/folder. A_WriteSkillsVersiontarget inMemory.Introspect.csprojstamps the package version intoskills/.skills-versionas the single source of truth.Memory.Introspect/buildTransitive/Memory.Introspect.targetsis auto-imported by referencing projects (buildTransitive/, so it reaches transitive consumers too). It walks up from the consuming project looking for a.claudefolder, compares the shipped.skills-versionagainst the installed marker, and wipes and re-copies the payload when they differ.
The targets filename must stay Memory.Introspect.targets (= the <PackageId>) for NuGet to
auto-import it; the install folder name is memory-introspect (the name: in SKILL.md) and
is set by _SkillsPackageId inside the targets file.
The skill is documentation, and drifts if the code changes underneath it. When you change the public surface, update it in the same change:
- New capture method or option — update the relevant
references/*.md(and the option tables intrace-collect.md/getting-started.md), andreferences/api-reference.md. - New concept worth its own page — add
references/<slug>.mdand list it in theSKILL.mdindex. - Changed default or behaviour — fix the tables that state it; several references quote the same defaults.
- Platform: Works on Windows, Linux, and macOS.
- Privileges: The process running the code must have sufficient privileges to access the target process via the Diagnostics Client. If capturing the current process, standard user privileges are usually sufficient.
- Runtime: Requires .NET 6 or later.
This project is licensed under the MIT License.
Note: This library is heavily based on the source code of the official diagnostics tools provided by the .NET team.
The core logic for EventPipe communication, graph construction, dump collection and trace collection is adapted from:
One
dotnet-tracecapability is intentionally not ported:collect-linux, which drives the Linuxperf_eventssubsystem through an external collector rather than EventPipe, and so cannot be done from inside a managed library. Its two profiles (cpu-sampling,thread-time) are therefore absent fromListTraceProfiles().We are grateful to the .NET Diagnostics team for their open-source contributions.