Skip to content

Add collect-linux mode to dotnet-trace integration - #887

Merged
LoopedBard3 merged 10 commits into
dotnet:mainfrom
LoopedBard3:loopedbard3/dotnet-trace-collect-linux
Jun 16, 2026
Merged

LoopedBard3 merged 10 commits into
dotnet:mainfrom
LoopedBard3:loopedbard3/dotnet-trace-collect-linux

Conversation

@LoopedBard3

@LoopedBard3 LoopedBard3 commented Jun 8, 2026 •

Copy link
Copy Markdown
Member

What

Adds a collect-linux mode to crank's dotnet-trace integration so Linux agents on modern kernels can capture whole-machine, perf_event-based traces with kernel + native frames, without LTTng.

New Job properties (defaults reproduce today's exact behavior):

  • DotNetTraceCollectMode (default | collect | collect-linux) — default keeps today's in-process DiagnosticsClient.StartEventPipeSession path; collect and collect-linux shell out to the dotnet-trace CLI.
  • DotNetTraceStopTimeoutSec — grace period after SIGINT before the CLI is killed. 0 selects the per-mode default (60s for collect, 180s for collect-linux).

Agent additions in Startup.cs:

  • Pinned dotnet-trace 10.0.721401 (latest on nuget.org with collect-linux; ships a linux-arm64 build).
  • Lazy dotnet tool install --tool-path on first CLI-mode use, with a post-install --version assert and a single-flight semaphore.
  • CollectViaDotNetTraceCliAsync spawns the CLI directly (not via ProcessUtil.RunAsync) so we keep lifecycle control: wait for the benchmark to signal stop → send SIGINT → wait per-mode grace → fall back to Kill(entireProcessTree:true) on expiry.
  • Grace-period expiry logs [WRN] dotnet-trace did not finalize within {N}s; trace may be incomplete and keeps the partial trace — does not fail the job.

Provider-string compatibility:

  • BuildDotnetTraceCliArgs classifies each token into profile / provider-spec / CLR-keyword-expression using crank's existing TraceExtensions table.
  • Required rewrite: cpu-sampling → dotnet-sampled-thread-time in collect mode only (the modern cpu-sampling profile has VerbExclusivity="collect-linux" and is rejected by the collect verb).
  • Cosmetic alias rewrites: fusion → assemblyloader, gcheapcollect → managedheapcollect, plus the two gcsampledobjectallcation{high,low} typos.
  • All rewrites are grouped into one [INF] dotnet-trace providers normalized: … line per job.
  • Empty-providers default is cpu-sampling for collect-linux and dotnet-sampled-thread-time,dotnet-common for collect. Empty-providers default for default mode is unchanged (still cpu-sampling via PR A's preserved behavior).

collect-linux preflight (hard fail — no silent fallback to collect, per design):

  • OS == Linux, kernel ≥ 6.4 (parsed from /proc/sys/kernel/osrelease), effective UID == 0 (via Mono.Unix.Native.Syscall.geteuid()).
  • Verbatim error text:
    DotNetTraceCollectMode=collect-linux requires Linux, root (effective UID 0), and kernel >= 6.4. Detected: OS={os}, EUID={euid}, kernel={ver}. Set DotNetTraceCollectMode=collect to use EventPipe collection instead.

Docs:

  • New flags documented in Documentation.cs + README.md.
  • Decision matrix expanded from 2 rows (PR A) → 3 rows: DotNetTrace=true (any OS), DotNetTrace=true + DotNetTraceCollectMode=collect-linux (modern Linux + root), Collect=true (legacy PerfView pipelines).

Why

LTTng-based Collect=true is structurally fragile on modern Ubuntu — cobalt-hosted Ubuntu 24.04 aarch64 returns empty managed-event sequences even with lttng-tools + liblttng-ust-dev installed correctly (likely runtime↔liblttng-ust ABI mismatch). dotnet-trace collect-linux is the perf_event-based replacement: same kernel-sampling capability as perfcollect provided, but without the LTTng dependency. Cobalt hosts (kernel 6.8, agent container runs as root) meet every prerequisite.

Existing --application.dotNetTrace true users keep their exact current behavior. collect-linux is purely opt-in via the new mode flag.

Notes

  • collect-linux rejects --buffersize (TreatUnmatchedTokensAsErrors=true in upstream's CollectLinuxCommand; perf_event uses kernel ring buffers, not EventPipe circular buffers). The argv builder strips it in that mode regardless of DotNetTraceBufferSizeMB.
  • Pinned tool version targets net8.0 (the tool's own TFM). collect-linux requires the traced process to be .NET 10+. If a user points it at a .NET 8/9 process, the CLI's stderr ("EventPipe IPC command not understood") is captured verbatim into job.Error.
  • Effective UID, not real UID. Mono.Unix.UnixUserInfo.RealUserId would return the pre-sudo UID under elevation; geteuid() is the signal that matters for perf_event_open capability checks. (Rubber-duck blind-spot fix from the planning session.)
  • Windows collect mode is best-effort. GenerateConsoleCtrlEvent doesn't reliably deliver Ctrl+C to non-console children spawned with UseShellExecute=false; the helper falls back to CloseMainWindow() + the grace-period kill. Windows users almost always want default mode anyway.

Mixed-version compatibility

Direction Behavior
New controller + old agent Old agent's deserializer ignores DotNetTraceCollectMode / DotNetTraceStopTimeoutSec. If the controller asks for collect-linux, the old agent silently runs default. Documented expectation.
New agent + old controller New properties default to today's exact behavior (default mode, 0-as-per-mode-default timeout). Byte-for-byte identical to today.

Validated by JobMixedVersionCompatTests covering legacy and PR-A-era payloads.

Tested

  • dotnet build Microsoft.Crank.sln -c Release → clean (0 errors, 0 warnings).
  • dotnet test test/Microsoft.Crank.UnitTests → 103 / 103 passing, including 33 new tests across:
    • DotNetTraceCliNormalizationTests (BuildDotnetTraceCliArgs / NormalizeClrEventExpression — defaults per mode, cpu-sampling rewrite, classification, alias rewrites, buffer-size gating)
    • DotNetTraceCollectLinuxPreflightTests (ParseKernelVersion across the 6.4 boundary, error-message contract on non-Linux)
    • Extended JobMixedVersionCompatTests (PR B knob defaults + round-trip)
  • ❌ Manual collect-linux validation on cobalt aarch64 Ubuntu 24.04 host — pending reviewer. Manual-validation checklist before un-drafting:
    • dotnet tool install dotnet-trace --version 10.0.721401 --tool-path /tmp/x resolves an arm64 tool on the cobalt host (no x64-only fallback).
    • A simple .NET 10 benchmark with --application.dotNetTrace true --application.dotNetTraceCollectMode collect-linux produces a non-empty .nettrace with both managed and native frames.
    • Same invocation on a non-Linux/non-root environment fails fast with the documented preflight error string (no silent fallback to collect).

Operator notes

dotnet-trace collect-linux reads tracefs to translate perf_event_open records. In a container the /sys/kernel/tracing directory exists but is empty unless the host's tracefs is bind-mounted, surfacing as Error: Tracefs is not accessible: It appears tracefs is not mounted. at runtime. This PR adds -v /sys/kernel/tracing:/sys/kernel/tracing to docker/agent/run.sh so the wrapper script handles this for new agent deployments. EventPipe-based collection (DotNetTraceCollectMode=collect or Collect=true perfcollect) does not need this mount; only collect-linux does. The host must also have tracefs mounted (Ubuntu 24.04 + systemd does this at boot; check with mount | grep tracefs). Operators upgrading an existing agent need to ./docker/agent/stop.sh && ./docker/agent/run.sh to pick up the new mount -- volume mounts cannot be added to a running container. Validated on the cobalt-hosted aarch64 Ubuntu 24.04 (kernel 6.8) host.

The pinned dotnet-trace CLI version is 10.0.721401 (tag v10.0.721401 on dotnet/diagnostics:release/stable, commit 3f576a7). This is the latest stable release that contains both upstream fixes for the no-tty SetCursorPosition(0, -1) crash that occurs when collect-linux is spawned with redirected stdout: dotnet/diagnostics#5771 ("Fix no-tty crash with console capability aware ProgressWriter") and dotnet/diagnostics#5745 ("Handle redirected input/output in collect-linux"). 10.x is not on nuget.org; the agent writes a hermetic NuGet.config next to the tool path and resolves from the dnceng public dotnet-tools feed via --configfile.

Stacking

PR A (#886) has merged into dotnet/crank:main as squash commit d392425. This branch has been rebased onto current main and now contains only the collect-linux commits.

@LoopedBard3 LoopedBard3 self-assigned this Jun 8, 2026
LoopedBard3 and others added 5 commits June 9, 2026 13:14
Layers a CLI-driven dotnet-trace collection path on top of PR A's
in-process EventPipe path, with collect-linux as a Linux-only opt-in
that captures kernel + native frames via perf_event_open.

What:
- New `Job.DotNetTraceCollectMode` (`default` | `collect` |
  `collect-linux`). Default preserves today's in-process behavior.
- New `Job.DotNetTraceStopTimeoutSec` (0 = per-mode default: 60s for
  `collect`, 180s for `collect-linux`).
- Agent lazily `dotnet tool install`s pinned `dotnet-trace 9.0.661903`
  on first CLI-mode use, asserts the reported version, caches the path.
- `StartDotNetTrace` dispatches on the mode. `collect` / `collect-linux`
  shell out via a new `CollectViaDotNetTraceCliAsync` that:
  * normalizes `DotNetTraceProviders` through crank's keyword table
    (rewrites `cpu-sampling`\u2192`dotnet-sampled-thread-time` for
    `collect` mode -- required, since modern `cpu-sampling` is
    collect-linux-exclusive; cosmetic alias rewrites for `fusion`,
    `gcheapcollect`, the `...allcation...` typos);
  * groups all rewrites into one `[INF]` line per job;
  * spawns the CLI directly (not via `ProcessUtil.RunAsync`) so we keep
    lifecycle control, signals SIGINT on stop, falls back to
    `Kill(entireProcessTree:true)` on grace expiry;
  * logs `[WRN]` on grace expiry but keeps the partial trace.
- `collect-linux` preflight is a HARD FAIL (no silent fallback):
  Linux + `geteuid()==0` + kernel >= 6.4. Uses the verbatim error
  text from the plan so it's greppable.

Why:
- LTTng-based `Collect=true` is structurally fragile on modern Ubuntu
  (empty managed event sequences on 24.04). `collect-linux` is the
  perf_event-based replacement that doesn't depend on LTTng.
- Existing `DotNetTrace=true` users keep their exact current behavior;
  `collect-linux` is purely opt-in.

Notes:
- Mixed-version compat: all new Job properties default to today's
  behavior. New controller + old agent silently uses `default` mode.
  New agent + old controller is byte-for-byte identical to today.
- `collect-linux` rejects `--buffersize` (perf_event ring buffers, not
  EventPipe); the builder strips it in that mode regardless of the knob.
- Stop timeout is per-mode by design: `collect-linux`'s rundown +
  native symbolication is genuinely heavier than `collect`'s.

Tested:
- `dotnet build Microsoft.Crank.sln -c Release` clean.
- `dotnet test test/Microsoft.Crank.UnitTests` 100/100 passing
  (33 new across BuildDotnetTraceCliArgs / NormalizeClrEventExpression
  / ParseKernelVersion / mixed-version-compat).
- Manual collect-linux validation on cobalt aarch64 Ubuntu 24.04 pending
  reviewer sign-off.

Stacked on loopedbard3/modernize-dotnet-trace (PR A).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…, hard-fail on version mismatch

Three Copilot review comments on PR #3 surfaced real backcompat / soundness issues in BuildDotnetTraceCliArgs and EnsureDotnetTraceCliInstalledAsync. All three are fixed here with regression tests.

1. Tokenizer split on ',' only; legacy Collect() splits on ',' and ' '. Users with `gc-collect gc+jit`-style configs would tokenize differently under the CLI modes. Fixed by adding ' ' to the split delimiters.

2. Token classifier routed any non-':', non-profile token to --clrevents. A bare provider name like Microsoft-DotNETCore-SampleProfiler (the example shown in the README) is neither, so it would hit --clrevents and be rejected by the CLI. Fixed by adding TraceExtensions.IsRecognizedClrKeywordExpression: classify as --clrevents only when every '+'-part is a known CLR keyword; otherwise fall through to --providers, which accepts bare provider names.

3. dotnet-trace --version mismatch with the pinned DotnetTraceVersion was warn-and-proceed. Defeats the assert's purpose: a previous run's wrong-version binary in toolPath silently shadows the pin. Promoted to hard fail (with _dotnetTracePath reset so a manual cleanup recovers), matching the 'no silent fallback' theme established for the collect-linux preflight.

Tests: +3 regression tests (BuildArgs_BareProviderName_GoesToProvidersFlag, BuildArgs_PlusJoinedWithUnknownPart_RoutesToProviders, BuildArgs_SpaceSeparatedTokens_AreTokenizedSeparately). Suite: 103 / 103 passing (was 100 / 100).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Position crash

When dotnet-trace collect-linux is spawned by crank as a child process with stdout/stderr redirected to .NET pipes, its progress LineRewriter calls Console.SetCursorPosition(0, -1) and crashes with ArgumentOutOfRangeException. Console.CursorTop returns -1 in a non-TTY context; the 9.0.661903 build we previously pinned doesn't guard against that.

Two upstream fixes addressing this are present in 10.0.721401 (tag v10.0.721401 on dotnet/diagnostics release/stable, commit 3f576a7):

  - dotnet/diagnostics#5771 'Fix no-tty crash with console capability aware ProgressWriter' (commit 767b7c0e)

  - dotnet/diagnostics#5745 'Handle redirected input/output in collect-linux' (commit 1f02e6f6)

10.x is not on nuget.org -- nuget.org currently tops out at 9.0.661903. Resolve the tool from the dnceng public dotnet-tools feed by writing a hermetic NuGet.config alongside the tool path and passing --configfile to dotnet tool install. A bare --add-source conflicts with package-source-mapping when the host has it configured, so --configfile is the portable form.

The 10.x package layout is tools/net8.0/any/ (managed-IL only), so linux-arm64 on the cobalt-hosted aarch64 box is satisfied by the 'any' RID -- no native bits needed.

Also redirect stdin in the child ProcessStartInfo as defense in depth: with IsInputRedirected true, the rewriter skips its ANSI-capability probe entirely (the path that produces the -1 from Console.CursorTop). Stop is signaled with SIGINT on Linux so we never write to stdin.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ect-linux

dotnet-trace collect-linux reads tracefs to translate perf_event_open ring-buffer records into symbolicated frames. In a container the /sys/kernel/tracing directory exists but is empty unless the host's tracefs is bind-mounted, surfacing as 'Tracefs is not accessible: It appears tracefs is not mounted.' at runtime.

Add -v /sys/kernel/tracing:/sys/kernel/tracing to the agent's docker run line. EventPipe-based collection (collect mode, or Collect=true perfcollect) does not need this mount; only collect-linux does. Host must have tracefs mounted -- Ubuntu 24.04 + systemd does this automatically at boot.

Validated on the cobalt-hosted aarch64 Ubuntu 24.04 (kernel 6.8) host.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removes the 3-row matrix that PR A's 2-row version was expanded into when
collect-linux mode was added. Same reasoning as the matching removal on
PR A's branch: keep the controller help text a flat flag reference and
rehome the decision guidance to a dedicated docs page later, once the
trace collection surface (collect-linux preview status, perfcollect
behavior on modern distros) stabilizes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@LoopedBard3
LoopedBard3 force-pushed the loopedbard3/dotnet-trace-collect-linux branch from 401fcbd to 0b06a0b Compare June 9, 2026 20:15
@LoopedBard3
LoopedBard3 requested a review from Copilot June 9, 2026 20:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new DotNetTraceCollectMode=collect-linux option to crank’s dotnet-trace integration, enabling perf_event-based whole-machine tracing on modern Linux (kernel + native frames) via the dotnet-trace CLI, without relying on LTTng/perfcollect, while keeping the legacy in-process EventPipe behavior as the default.

Changes:

  • Introduces DotNetTraceCollectMode and DotNetTraceStopTimeoutSec Job knobs (defaults preserve existing behavior).
  • Adds agent-side dotnet-trace CLI installation + lifecycle management, plus collect-linux prerequisite validation and provider-token normalization for CLI modes.
  • Expands documentation and unit tests to cover mixed-version compatibility, CLI arg normalization, and Linux preflight parsing/contract.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Microsoft.Crank.Agent/Startup.cs Implements CLI-based collect / collect-linux modes, lazy tool install, preflight checks, argv builder, and stop/grace/kill behavior.
src/Microsoft.Crank.Agent/TraceExtensions.cs Adds CLR keyword-expression recognition helper used for CLI token classification.
src/Microsoft.Crank.Models/Job.cs Adds DotNetTraceCollectMode + DotNetTraceStopTimeoutSec with legacy-preserving defaults.
src/Microsoft.Crank.Controller/Documentation.cs Documents the new dotnet-trace mode and stop-timeout CLI flags.
src/Microsoft.Crank.Controller/README.md Updates controller help text for the new dotnet-trace flags.
docker/agent/run.sh Adds /sys/kernel/tracing bind mount required for dotnet-trace collect-linux tracefs access.
test/Microsoft.Crank.UnitTests/DotNetTraceCliNormalizationTests.cs Adds unit tests for CLI argv building, token classification, and alias normalization behavior.
test/Microsoft.Crank.UnitTests/DotNetTraceCollectLinuxPreflightTests.cs Adds unit tests for kernel-version parsing and the non-Linux error-message contract.
test/Microsoft.Crank.UnitTests/JobMixedVersionCompatTests.cs Extends mixed-version deserialization/roundtrip tests to include PR-B knobs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/Microsoft.Crank.Agent/Startup.cs Outdated
Comment thread test/Microsoft.Crank.UnitTests/DotNetTraceCollectLinuxPreflightTests.cs Outdated
Comment thread test/Microsoft.Crank.UnitTests/DotNetTraceCliNormalizationTests.cs Outdated
Copilot AI added 2 commits June 9, 2026 13:27
The pinned dotnet-trace version (10.0.721401) only lives on the dnceng

public dotnet-tools feed; nuget.org tops out at 9.0.661903 and cannot

supply it. Listing nuget.org here is dead weight at best and a footgun

at worst (an org-scoped 9.x mirror could shadow the intended 10.x).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Startup.cs: align EnsureDotnetTraceCliInstalledAsync comment with code (throws, not warns, on version mismatch).

- DotNetTraceCollectLinuxPreflightTests: drop dead Build==-1 branch; ParseKernelVersion always returns a 3-part Version.

- DotNetTraceCliNormalizationTests: rename LeavesUnknownKeywordsAlone -> LeavesNonAliasedKeywordExpressionAlone for clarity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@LoopedBard3
LoopedBard3 marked this pull request as ready for review June 9, 2026 20:44
@LoopedBard3
LoopedBard3 enabled auto-merge (squash) June 9, 2026 20:44
@LoopedBard3 LoopedBard3 added enhancement New feature or request dependencies Pull requests that update a dependency file labels Jun 9, 2026
@LoopedBard3

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

Comment thread src/Microsoft.Crank.Agent/Startup.cs Outdated
Comment thread src/Microsoft.Crank.Agent/Startup.cs
Comment thread src/Microsoft.Crank.Agent/Startup.cs Outdated
Comment thread src/Microsoft.Crank.Agent/Startup.cs Outdated
Comment thread src/Microsoft.Crank.Agent/Startup.cs
Comment thread src/Microsoft.Crank.Agent/Startup.cs
Comment thread src/Microsoft.Crank.Agent/Startup.cs Outdated
Copilot AI added 2 commits June 16, 2026 14:15
Addresses PR dotnet#887 review feedback: move DotNetTraceCollectMode validation

(and the collect-linux host prerequisite check) out of StartDotNetTrace --

which runs only after the app has launched -- and into the JobState.New

acceptance path, so a bad mode or unsupported host fails before any asset

restore, clone/build, or app launch.

- New ValidateDotNetTraceOptions(job) centralizes the mode allow-list and

  delegates collect-linux to ValidateCollectLinuxPrerequisites.

- StartDotNetTrace's collect-linux arm no longer re-runs the preflight;

  the default arm is now a defensive-only guard.

- 12 new unit tests cover the gate, mode allow-list, unknown-mode

  rejection, profile-type gating, and collect-linux delegation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses PR dotnet#887 review: the SemaphoreSlim only serialized concurrent

installs within a single agent process and could not coordinate across

separate crank agent processes. The SDK install path

(EnsureDotnetInstallExistsAsync) uses no such lock, so this was

inconsistent. Rely on the File.Exists idempotency short-circuit instead,

matching the established pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread src/Microsoft.Crank.Agent/Startup.cs
Addresses PR dotnet#887 review: replace the IndexOfAny delimiter list in

ParseKernelVersion with an anchored regex (^(\d+)\.(\d+)(?:\.(\d+))?).

The regex captures the leading major.minor[.patch] and naturally stops

at any suffix delimiter, so enumerating '-', '+', ' ', '~' is no longer

needed. Added test cases for ~ and space suffixes plus bare major.minor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread src/Microsoft.Crank.Agent/Startup.cs

@DrewScoggins DrewScoggins left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not convinced on the necessity of the euid validation method, and still think we should get rid of the Go-style tuple return, but those aren't pr stoppers.

@LoopedBard3
LoopedBard3 merged commit b83cb6f into dotnet:main Jun 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants