Skip to content

Commit d4961a0

Browse files
authored
[dotnet-trace] Add collect-linux verb (#5570)
Implements dotnet/docs#47894 Following the addition of emitting native runtime and custom EventSource events as user_events through dotnet/runtime#115265 and the public release of https://github.com/microsoft/one-collect which supports collecting both .NET user_events and Linux perf events into a single .nettrace file, `dotnet-trace` will support a new verb, `collect-linux`, that wraps around `record-trace`. This PR does the following: - Adds `collect-linux` verb and serializes a subset of `dotnet-trace collect` options in addition to a `collect-linux` specific `--perf-events` option into `record-trace` args. (see dotnet/docs#47894 for overarching details) - Adds record-trace dynamic library to `dotnet-trace` - Updates existing profiles (`cpu-sampling` -> `dotnet-common` + `dotnet-sampled-thread-time`) and adds `collect-linux` specific profiles - Updates `list-profiles` verb with revamped profiles + multiline description formatting - Refactors `EventPipeProvider` composition logic (`MergeProfileAndProviders` + `ToProviders` -> `ComputeProviderConfig`) and rename `Extensions.cs` -> `ProviderUtils.cs` - Revamp EventPipeProvider composition tests (`ProviderParsing.cs` -> `ProviderCompositionTests.cs`) - Various cleanup: Update CLREventKeywords + Update logging + refactor `collect` logic + expand `dotnet-trace` common options ## Testing ### dotnet-trace collect-linux On Linux <details> <summary>collect-linux</summary> <details> <summary>collect-linux --help</summary> ```bash $ ./dotnet-trace collect-linux -h Description: Collects diagnostic traces using perf_events, a Linux OS technology. collect-linux requires admin privileges to capture kernel- and user-mode events, and by default, captures events from all processes. This Linux-only command includes the same .NET events as dotnet-trace collect, and it uses the kernel’s user_events mechanism to emit .NET events as perf events, enabling unification of user-space .NET events with kernel-space system events. Usage: dotnet-trace collect-linux [options] Options: --providers A comma delimited list of EventPipe providers to be enabled. This is in the form 'Provider[,Provider]',where Provider is in the form: 'KnownProviderName[:[Flags][:[Level][:[KeyValueArgs]]]]', and KeyValueArgs is in the form: '[key1=value1][;key2=value2]'. Values in KeyValueArgs that contain ';' or '=' characters need to be surrounded by '"', e.g., FilterAndPayloadSpecs="MyProvider/MyEvent:-Prop1=Prop1;Prop2=Prop2.A.B;". Depending on your shell, you may need to escape the '"' characters and/or surround the entire provider specification in quotes, e.g., --providers 'KnownProviderName:0x1:1:FilterSpec=\"KnownProviderName/EventName:-Prop1=Prop1;Prop2=Prop2.A.B;\"'. These providers are in addition to any providers implied by the --profile argument. If there is any discrepancy for a particular provider, the configuration here takes precedence over the implicit configuration from the profile. See documentation for examples. --clreventlevel Verbosity of CLR events to be emitted. --clrevents List of CLR runtime events to emit. --perf-events Comma-separated list of perf events (e.g. syscalls:sys_enter_execve,sched:sched_switch). --profile A named, pre-defined set of provider configurations for common tracing scenarios. You can specify multiple profiles as a comma-separated list. When multiple profiles are specified, the providers and settings are combined (union), and duplicates are ignored. -o, --output The output path for the collected trace data. If not specified it defaults to '<appname>_<yyyyMMdd>_<HHmmss>.nettrace', e.g., 'myapp_20210315_111514.nettrace'. [default: default] --duration When specified, will trace for the given timespan and then automatically stop the trace. Provided in the form of dd:hh:mm:ss. -?, -h, --help Show help and usage information ``` </details> <details> <summary>`collect-linux` without elevated privileges</summary> ```bash $ ./dotnet-trace collect-linux ========================================================================================== The collect-linux verb is in preview. Some usage scenarios may not yet be supported, and some trace parsers may not yet support NetTrace V6. For any bugs or unexpected behaviors, please open an issue at https://github.com/dotnet/diagnostics/issues. ========================================================================================== No providers, profiles, ClrEvents, or PerfEvents were specified, defaulting to trace profiles 'dotnet-common' + 'cpu-sampling'. Provider Name Keywords Level Enabled By Microsoft-Windows-DotNETRuntime 0x000000100003801D Informational(4) --profile Linux Perf Events Enabled By cpu-sampling --profile Output File : /home/mihw/repo/diagnostics/trace_20251022_152234.nettrace Error: Tracefs is not accessible: Permission denied (os error 13) ``` </details> <details> <summary>`collect-linux` with elevated privileges</summary> ```bash $ sudo ./dotnet-trace collect-linux ========================================================================================== The collect-linux verb is in preview. Some usage scenarios may not yet be supported, and some trace parsers may not yet support NetTrace V6. For any bugs or unexpected behaviors, please open an issue at https://github.com/dotnet/diagnostics/issues. ========================================================================================== No providers, profiles, ClrEvents, or PerfEvents were specified, defaulting to trace profiles 'dotnet-common' + 'cpu-sampling'. Provider Name Keywords Level Enabled By Microsoft-Windows-DotNETRuntime 0x000000100003801D Informational(4) --profile Linux Perf Events Enabled By cpu-sampling --profile Output File : /home/mihw/repo/diagnostics/trace_20251022_152124.nettrace [00:00:00:21] Recording trace. Press <Enter> or <Ctrl-C> to exit... Recording stopped. Resolving symbols. Finished recording trace. Trace written to /home/mihw/repo/diagnostics/trace_20251022_152124.nettrace ``` </details> </details> On Windows (and I presume other non-Linux OS): <details> <summary>`collect-linux`</summary> ```pwsh .\artifacts\bin\dotnet-trace\Debug\net8.0\dotnet-trace.exe collect-linux The collect-linux command is only supported on Linux. ``` </details> ### dotnet-trace list-profiles <details> <summary>`list-profiles`</summary> ```bash dotnet-trace profiles: dotnet-common - Lightweight .NET runtime diagnostics designed to stay low overhead. Includes GC, AssemblyLoader, Loader, JIT, Exceptions, Threading, JittedMethodILToNativeMap, and Compilation events Equivalent to --providers "Microsoft-Windows-DotNETRuntime:0x100003801D:4". dotnet-sampled-thread-time (collect) - Samples .NET thread stacks (~100 Hz) toestimate how much wall clock time code is using. gc-verbose - Tracks GC collections and samples object allocations. gc-collect - Tracks GC collections only at very low overhead. database - Captures ADO.NET and Entity Framework database commands cpu-sampling (collect-linux) - Kernel CPU sampling events for measuring CPU usage. thread-time (collect-linux) - Kernel thread context switch events for measuring CPU usage and wall clock time ``` </details> <img width="215" height="172" alt="Screenshot 2025-09-19 142848" src="https://github.com/user-attachments/assets/9229b2f0-92fa-4c87-89e7-505c55f7ae6a" /> <img width="730" height="1049" alt="Screenshot 2025-09-19 142945" src="https://github.com/user-attachments/assets/4be3f222-0495-4f93-979c-43d68cd5f964" /> <img width="1903" height="823" alt="Screenshot 2025-09-19 142858" src="https://github.com/user-attachments/assets/370648f8-a5b5-436e-a9dc-73bffa0b2ad4" />
1 parent fcb028b commit d4961a0

18 files changed

+1370
-641
lines changed

src/Tools/Common/Commands/Utils.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ internal enum ReturnCode
217217
SessionCreationError,
218218
TracingError,
219219
ArgumentError,
220+
PlatformNotSupportedError,
220221
UnknownError
221222
}
222223
}

src/Tools/dotnet-counters/dotnet-counters.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
</PropertyGroup>
1313

1414
<ItemGroup>
15-
<Compile Include="$(MSBuildThisFileDirectory)..\dotnet-trace\Extensions.cs" Link="Extensions.cs" />
1615
<Compile Include="..\Common\Commands\ProcessStatus.cs" Link="ProcessStatus.cs" />
1716
<Compile Include="..\Common\Rendering\Interop.Windows.cs" Link="VirtualTerminalMode.Interop.Windows.cs" />
1817
<Compile Include="..\Common\Rendering\VirtualTerminalMode.cs" Link="VirtualTerminalMode.cs" />

src/Tools/dotnet-trace/CommandLine/Commands/CollectCommand.cs

Lines changed: 46 additions & 124 deletions
Large diffs are not rendered by default.
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
using System;
4+
using System.Collections.Generic;
5+
using System.CommandLine;
6+
using System.Diagnostics;
7+
using System.IO;
8+
using System.Linq;
9+
using System.Runtime.InteropServices;
10+
using System.Text;
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
using Microsoft.Diagnostics.NETCore.Client;
14+
using Microsoft.Diagnostics.Tools.Common;
15+
using Microsoft.Internal.Common.Utils;
16+
17+
namespace Microsoft.Diagnostics.Tools.Trace
18+
{
19+
internal partial class CollectLinuxCommandHandler
20+
{
21+
private bool stopTracing;
22+
private Stopwatch stopwatch = new();
23+
private LineRewriter rewriter;
24+
private bool printingStatus;
25+
26+
internal sealed record CollectLinuxArgs(
27+
CancellationToken Ct,
28+
string[] Providers,
29+
string ClrEventLevel,
30+
string ClrEvents,
31+
string[] PerfEvents,
32+
string[] Profiles,
33+
FileInfo Output,
34+
TimeSpan Duration);
35+
36+
public CollectLinuxCommandHandler(IConsole console = null)
37+
{
38+
Console = console ?? new DefaultConsole(false);
39+
rewriter = new LineRewriter(Console);
40+
}
41+
42+
/// <summary>
43+
/// Collects diagnostic traces using perf_events, a Linux OS technology. collect-linux requires admin privileges to capture kernel- and user-mode events, and by default, captures events from all processes.
44+
/// This Linux-only command includes the same .NET events as dotnet-trace collect, and it uses the kernel’s user_events mechanism to emit .NET events as perf events, enabling unification of user-space .NET events with kernel-space system events.
45+
/// </summary>
46+
internal int CollectLinux(CollectLinuxArgs args)
47+
{
48+
if (!OperatingSystem.IsLinux())
49+
{
50+
Console.Error.WriteLine("The collect-linux command is only supported on Linux.");
51+
return (int)ReturnCode.PlatformNotSupportedError;
52+
}
53+
54+
Console.WriteLine("==========================================================================================");
55+
Console.WriteLine("The collect-linux verb is a new preview feature and relies on an updated version of the");
56+
Console.WriteLine(".nettrace file format. The latest PerfView release supports these trace files but other");
57+
Console.WriteLine("ways of using the trace file may not work yet. For more details, see the docs at");
58+
Console.WriteLine("https://learn.microsoft.com/dotnet/core/diagnostics/dotnet-trace.");
59+
Console.WriteLine("==========================================================================================");
60+
61+
args.Ct.Register(() => stopTracing = true);
62+
int ret = (int)ReturnCode.TracingError;
63+
string scriptPath = null;
64+
try
65+
{
66+
Console.CursorVisible = false;
67+
byte[] command = BuildRecordTraceArgs(args, out scriptPath);
68+
69+
if (args.Duration != default)
70+
{
71+
System.Timers.Timer durationTimer = new(args.Duration.TotalMilliseconds);
72+
durationTimer.Elapsed += (sender, e) =>
73+
{
74+
durationTimer.Stop();
75+
stopTracing = true;
76+
};
77+
durationTimer.Start();
78+
}
79+
stopwatch.Start();
80+
ret = RecordTraceInvoker(command, (UIntPtr)command.Length, OutputHandler);
81+
}
82+
catch (CommandLineErrorException e)
83+
{
84+
Console.Error.WriteLine($"[ERROR] {e.Message}");
85+
ret = (int)ReturnCode.TracingError;
86+
}
87+
catch (Exception ex)
88+
{
89+
Console.Error.WriteLine($"[ERROR] {ex}");
90+
ret = (int)ReturnCode.TracingError;
91+
}
92+
finally
93+
{
94+
if (!string.IsNullOrEmpty(scriptPath))
95+
{
96+
try
97+
{
98+
if (File.Exists(scriptPath))
99+
{
100+
File.Delete(scriptPath);
101+
}
102+
} catch { }
103+
}
104+
}
105+
106+
return ret;
107+
}
108+
109+
public static Command CollectLinuxCommand()
110+
{
111+
Command collectLinuxCommand = new("collect-linux")
112+
{
113+
CommonOptions.ProvidersOption,
114+
CommonOptions.CLREventLevelOption,
115+
CommonOptions.CLREventsOption,
116+
PerfEventsOption,
117+
CommonOptions.ProfileOption,
118+
CommonOptions.OutputPathOption,
119+
CommonOptions.DurationOption,
120+
};
121+
collectLinuxCommand.TreatUnmatchedTokensAsErrors = true; // collect-linux currently does not support child process tracing.
122+
collectLinuxCommand.Description = "Collects diagnostic traces using perf_events, a Linux OS technology. collect-linux requires admin privileges to capture kernel- and user-mode events, and by default, captures events from all processes. This Linux-only command includes the same .NET events as dotnet-trace collect, and it uses the kernel’s user_events mechanism to emit .NET events as perf events, enabling unification of user-space .NET events with kernel-space system events.";
123+
124+
collectLinuxCommand.SetAction((parseResult, ct) => {
125+
string providersValue = parseResult.GetValue(CommonOptions.ProvidersOption) ?? string.Empty;
126+
string perfEventsValue = parseResult.GetValue(PerfEventsOption) ?? string.Empty;
127+
string profilesValue = parseResult.GetValue(CommonOptions.ProfileOption) ?? string.Empty;
128+
CollectLinuxCommandHandler handler = new();
129+
130+
int rc = handler.CollectLinux(new CollectLinuxArgs(
131+
Ct: ct,
132+
Providers: providersValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
133+
ClrEventLevel: parseResult.GetValue(CommonOptions.CLREventLevelOption) ?? string.Empty,
134+
ClrEvents: parseResult.GetValue(CommonOptions.CLREventsOption) ?? string.Empty,
135+
PerfEvents: perfEventsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
136+
Profiles: profilesValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
137+
Output: parseResult.GetValue(CommonOptions.OutputPathOption) ?? new FileInfo(CommonOptions.DefaultTraceName),
138+
Duration: parseResult.GetValue(CommonOptions.DurationOption)));
139+
return Task.FromResult(rc);
140+
});
141+
142+
return collectLinuxCommand;
143+
}
144+
145+
private byte[] BuildRecordTraceArgs(CollectLinuxArgs args, out string scriptPath)
146+
{
147+
scriptPath = null;
148+
List<string> recordTraceArgs = new();
149+
150+
string[] profiles = args.Profiles;
151+
if (args.Profiles.Length == 0 && args.Providers.Length == 0 && string.IsNullOrEmpty(args.ClrEvents) && args.PerfEvents.Length == 0)
152+
{
153+
Console.WriteLine("No providers, profiles, ClrEvents, or PerfEvents were specified, defaulting to trace profiles 'dotnet-common' + 'cpu-sampling'.");
154+
profiles = new[] { "dotnet-common", "cpu-sampling" };
155+
}
156+
157+
StringBuilder scriptBuilder = new();
158+
List<EventPipeProvider> providerCollection = ProviderUtils.ComputeProviderConfig(args.Providers, args.ClrEvents, args.ClrEventLevel, profiles, true, "collect-linux", Console);
159+
foreach (EventPipeProvider provider in providerCollection)
160+
{
161+
string providerName = provider.Name;
162+
string providerNameSanitized = providerName.Replace('-', '_').Replace('.', '_');
163+
long keywords = provider.Keywords;
164+
uint eventLevel = (uint)provider.EventLevel;
165+
IDictionary<string, string> arguments = provider.Arguments;
166+
if (arguments != null && arguments.Count > 0)
167+
{
168+
scriptBuilder.Append($"set_dotnet_filter_args(\n\t\"{providerName}\"");
169+
foreach ((string key, string value) in arguments)
170+
{
171+
scriptBuilder.Append($",\n\t\"{key}={value}\"");
172+
}
173+
scriptBuilder.Append($");\n");
174+
}
175+
176+
scriptBuilder.Append($"let {providerNameSanitized}_flags = new_dotnet_provider_flags();\n");
177+
scriptBuilder.Append($"record_dotnet_provider(\"{providerName}\", 0x{keywords:X}, {eventLevel}, {providerNameSanitized}_flags);\n\n");
178+
}
179+
180+
List<string> linuxEventLines = new();
181+
foreach (string profile in profiles)
182+
{
183+
Profile traceProfile = ListProfilesCommandHandler.TraceProfiles
184+
.FirstOrDefault(p => p.Name.Equals(profile, StringComparison.OrdinalIgnoreCase));
185+
186+
if (traceProfile != null &&
187+
!string.IsNullOrEmpty(traceProfile.VerbExclusivity) &&
188+
traceProfile.VerbExclusivity.Equals("collect-linux", StringComparison.OrdinalIgnoreCase))
189+
{
190+
recordTraceArgs.Add(traceProfile.CollectLinuxArgs);
191+
linuxEventLines.Add($"{traceProfile.Name,-80}--profile");
192+
}
193+
}
194+
195+
foreach (string perfEvent in args.PerfEvents)
196+
{
197+
string[] split = perfEvent.Split(':', 2, StringSplitOptions.TrimEntries);
198+
if (split.Length != 2 || string.IsNullOrEmpty(split[0]) || string.IsNullOrEmpty(split[1]))
199+
{
200+
throw new CommandLineErrorException($"Invalid perf event specification '{perfEvent}'. Expected format 'provider:event'.");
201+
}
202+
203+
string perfProvider = split[0];
204+
string perfEventName = split[1];
205+
linuxEventLines.Add($"{perfEvent,-80}--perf-events");
206+
scriptBuilder.Append($"let {perfEventName} = event_from_tracefs(\"{perfProvider}\", \"{perfEventName}\");\nrecord_event({perfEventName});\n\n");
207+
}
208+
209+
if (linuxEventLines.Count > 0)
210+
{
211+
Console.WriteLine($"{("Linux Perf Events"),-80}Enabled By");
212+
foreach (string line in linuxEventLines)
213+
{
214+
Console.WriteLine(line);
215+
}
216+
}
217+
else
218+
{
219+
Console.WriteLine("No Linux Perf Events enabled.");
220+
}
221+
Console.WriteLine();
222+
223+
FileInfo resolvedOutput = ResolveOutputPath(args.Output);
224+
recordTraceArgs.Add($"--out");
225+
recordTraceArgs.Add(resolvedOutput.FullName);
226+
Console.WriteLine($"Output File : {resolvedOutput.FullName}");
227+
Console.WriteLine();
228+
229+
string scriptText = scriptBuilder.ToString();
230+
scriptPath = Path.ChangeExtension(resolvedOutput.FullName, ".script");
231+
File.WriteAllText(scriptPath, scriptText);
232+
233+
recordTraceArgs.Add("--script-file");
234+
recordTraceArgs.Add(scriptPath);
235+
236+
string options = string.Join(' ', recordTraceArgs);
237+
return Encoding.UTF8.GetBytes(options);
238+
}
239+
240+
private static FileInfo ResolveOutputPath(FileInfo output)
241+
{
242+
if (!string.Equals(output.Name, CommonOptions.DefaultTraceName, StringComparison.OrdinalIgnoreCase))
243+
{
244+
return output;
245+
}
246+
247+
DateTime now = DateTime.Now;
248+
return new FileInfo($"trace_{now:yyyyMMdd}_{now:HHmmss}.nettrace");
249+
}
250+
251+
private int OutputHandler(uint type, IntPtr data, UIntPtr dataLen)
252+
{
253+
OutputType ot = (OutputType)type;
254+
if (dataLen != UIntPtr.Zero && (ulong)dataLen <= int.MaxValue)
255+
{
256+
string text = Marshal.PtrToStringUTF8(data, (int)dataLen);
257+
if (!string.IsNullOrEmpty(text) &&
258+
!text.StartsWith("Recording started", StringComparison.OrdinalIgnoreCase))
259+
{
260+
if (ot == OutputType.Error)
261+
{
262+
Console.Error.WriteLine(text);
263+
stopTracing = true;
264+
}
265+
else
266+
{
267+
Console.Out.WriteLine(text);
268+
}
269+
}
270+
}
271+
272+
if (ot == OutputType.Progress)
273+
{
274+
if (printingStatus)
275+
{
276+
rewriter.RewriteConsoleLine();
277+
}
278+
else
279+
{
280+
printingStatus = true;
281+
rewriter.LineToClear = Console.CursorTop - 1;
282+
}
283+
Console.Out.WriteLine($"[{stopwatch.Elapsed:dd\\:hh\\:mm\\:ss}]\tRecording trace.");
284+
Console.Out.WriteLine("Press <Enter> or <Ctrl-C> to exit...");
285+
286+
if (Console.KeyAvailable && Console.ReadKey(true).Key == ConsoleKey.Enter)
287+
{
288+
stopTracing = true;
289+
}
290+
}
291+
292+
return stopTracing ? 1 : 0;
293+
}
294+
295+
private static readonly Option<string> PerfEventsOption =
296+
new("--perf-events")
297+
{
298+
Description = @"Comma-separated list of perf events (e.g. syscalls:sys_enter_execve,sched:sched_switch)."
299+
};
300+
301+
private enum OutputType : uint
302+
{
303+
Normal = 0,
304+
Live = 1,
305+
Error = 2,
306+
Progress = 3,
307+
}
308+
309+
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
310+
internal delegate int recordTraceCallback(
311+
[In] uint type,
312+
[In] IntPtr data,
313+
[In] UIntPtr dataLen);
314+
315+
[LibraryImport("recordtrace", EntryPoint = "RecordTrace")]
316+
private static partial int RunRecordTrace(
317+
byte[] command,
318+
UIntPtr commandLen,
319+
recordTraceCallback callback);
320+
321+
#region testing seams
322+
internal Func<byte[], UIntPtr, recordTraceCallback, int> RecordTraceInvoker { get; set; } = RunRecordTrace;
323+
internal IConsole Console { get; set; }
324+
#endregion
325+
}
326+
}

src/Tools/dotnet-trace/CommandLine/Commands/ConvertCommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ public static Command ConvertCommand()
116116
private static readonly Argument<FileInfo> InputFileArgument =
117117
new Argument<FileInfo>(name: "input-filename")
118118
{
119-
Description = $"Input trace file to be converted. Defaults to '{CollectCommandHandler.DefaultTraceName}'.",
120-
DefaultValueFactory = _ => new FileInfo(CollectCommandHandler.DefaultTraceName),
119+
Description = $"Input trace file to be converted. Defaults to '{CommonOptions.DefaultTraceName}'.",
120+
DefaultValueFactory = _ => new FileInfo(CommonOptions.DefaultTraceName),
121121
}.AcceptExistingOnly();
122122

123123
private static readonly Option<FileInfo> OutputOption =

0 commit comments

Comments
 (0)