diff --git a/managed/.github/workflows/ci.yml b/managed/.github/workflows/ci.yml
new file mode 100644
index 0000000..be19060
--- /dev/null
+++ b/managed/.github/workflows/ci.yml
@@ -0,0 +1,215 @@
+name: CI
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+
+jobs:
+ # Linux: build everything and run the unit tests. The net472 / net462
+ # targets build here against the Microsoft.NETFramework.ReferenceAssemblies
+ # package (compile-only).
+ build-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+ - name: Build
+ run: dotnet build AppMap.sln -c Release
+ - name: Test
+ run: dotnet test test/AppMap.Agent.Tests/AppMap.Agent.Tests.csproj -c Release --no-build
+
+ # Windows: real .NET Framework reference assemblies, and — the point of
+ # this job — actual validation of the classic (native) Windows PDB
+ # fallback. HelloAppMap is built with full, which
+ # produces a non-portable PDB, forcing SourceLocator down the
+ # WindowsPdbReader/diasymreader path. We then assert the recorded AppMap
+ # carries source locations, which only happens if that path works.
+ windows:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+ - name: Build solution
+ run: dotnet build AppMap.sln -c Release
+ - name: Test
+ run: dotnet test test/AppMap.Agent.Tests/AppMap.Agent.Tests.csproj -c Release --no-build
+
+ - name: Record HelloAppMap with a native (full) PDB
+ shell: pwsh
+ working-directory: examples/HelloAppMap
+ env:
+ APPMAP_RECORD_PROCESS: "true"
+ APPMAP_DEBUG: "true"
+ run: |
+ dotnet build -c Release -p:DebugType=full
+ $hook = "$PWD/../../src/AppMap.StartupHook/bin/Release/net8.0/AppMap.StartupHook.dll"
+ $env:DOTNET_STARTUP_HOOKS = (Resolve-Path $hook)
+ dotnet run -c Release --no-build
+
+ - name: Assert source locations resolved from the Windows PDB
+ shell: pwsh
+ working-directory: examples/HelloAppMap
+ run: |
+ $map = Get-ChildItem -Recurse tmp/appmap/process_recording/*.appmap.json |
+ Select-Object -First 1
+ if (-not $map) { throw "no AppMap was produced" }
+ $json = Get-Content $map.FullName -Raw | ConvertFrom-Json
+ $locations = @()
+ function Walk($node) {
+ if ($node.location) { $script:locations += $node.location }
+ foreach ($child in $node.children) { Walk $child }
+ }
+ foreach ($root in $json.classMap) { Walk $root }
+ Write-Host "resolved $($locations.Count) source location(s):"
+ $locations | ForEach-Object { Write-Host " $_" }
+ if ($locations.Count -eq 0) {
+ throw "Windows PDB fallback produced no source locations"
+ }
+ # The path must be repo-relative with forward slashes, not the
+ # build machine's C:\...; this is what lets a Windows recording be
+ # queried on Linux (see the cross-platform-query job).
+ $abs = $locations | Where-Object { $_ -match '\\' -or $_ -match '^[A-Za-z]:[\\/]' }
+ if ($abs) { throw "absolute/backslash path(s) leaked: $abs" }
+
+ - name: Upload the Windows-recorded AppMap
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-appmaps
+ path: examples/HelloAppMap/tmp/appmap
+
+ # R4 cross-platform: a map RECORDED ON WINDOWS (above) must INDEX and QUERY
+ # ON LINUX, with its source paths resolving against a Linux checkout. This
+ # is the agent's headline claim and is only possible because SourceLocator
+ # emits repo-relative paths.
+ cross-platform-query:
+ needs: windows
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install AppMap CLI
+ run: |
+ npm install -g @appland/appmap --ignore-scripts
+ # index/query use better-sqlite3's native binding, skipped by --ignore-scripts.
+ ( cd "$(npm root -g)/@appland/appmap" && npm rebuild better-sqlite3 )
+ - name: Download the Windows-recorded AppMap
+ uses: actions/download-artifact@v4
+ with:
+ name: windows-appmaps
+ path: windows-maps
+ - name: Index and query the Windows map on Linux
+ run: |
+ appmap index --appmap-dir windows-maps
+ map=$(find windows-maps -name '*.appmap.json' | head -1)
+ test -n "$map" || { echo "no map in artifact"; exit 1; }
+ appmap sequence-diagram -f json "$map" > /dev/null
+ echo "queried $map on Linux"
+ - name: Assert the Windows paths resolve against the Linux checkout
+ run: |
+ python3 - <<'PY'
+ import json, glob, os
+ maps = glob.glob('windows-maps/**/*.appmap.json', recursive=True)
+ assert maps, "no maps in artifact"
+ doc = json.load(open(maps[0]))
+ locs = set()
+ def walk(n):
+ if n.get('location'): locs.add(n['location'].split(':')[0])
+ for c in n.get('children', []): walk(c)
+ for r in doc.get('classMap', []): walk(r)
+ assert locs, "Windows map carried no source locations"
+ missing = [p for p in locs if not os.path.isfile(p)]
+ print("locations:", sorted(locs))
+ assert not missing, f"paths do not resolve on Linux: {missing}"
+ print(f"OK: {len(locs)} Windows-recorded path(s) resolve against the Linux checkout")
+ PY
+
+ # R1: validate the classic-ASP.NET (System.Web) IHttpModule actually records
+ # on a real IIS host (IIS Express) — the legacy .NET Framework attach path,
+ # which the unit/PDB jobs don't exercise. The app references no AppMap
+ # source; the agent attaches only by being registered in web.config.
+ system-web-iis:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+ - name: Build the System.Web module (net472)
+ run: dotnet build src/AppMap.SystemWeb/AppMap.SystemWeb.csproj -c Release
+ - name: Record a request through IIS Express
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = "Stop"
+ $site = Join-Path $env:RUNNER_TEMP "site"
+ New-Item -ItemType Directory -Force (Join-Path $site "bin") | Out-Null
+ Copy-Item harness/fixtures/SystemWebApp/* $site -Recurse -Force
+ # Drop the agent (module + Agent + Harmony + deps) into the app's bin.
+ Copy-Item src/AppMap.SystemWeb/bin/Release/net472/*.dll (Join-Path $site "bin") -Force
+
+ # The agent is netstandard2.0; under .NET Framework its transitive
+ # System.* package assemblies need binding redirects or they throw
+ # FileLoadException at runtime (and the module silently records
+ # nothing). Generate redirects from the actual bin assemblies.
+ $deps = ""
+ Get-ChildItem (Join-Path $site "bin\*.dll") | ForEach-Object {
+ try {
+ $an = [System.Reflection.AssemblyName]::GetAssemblyName($_.FullName)
+ $tokenBytes = $an.GetPublicKeyToken()
+ if ($tokenBytes -and $tokenBytes.Length -gt 0) {
+ $token = ($tokenBytes | ForEach-Object { $_.ToString("x2") }) -join ""
+ $deps += ""
+ }
+ } catch {}
+ }
+ $runtime = "$deps"
+ $cfg = Join-Path $site "web.config"
+ (Get-Content $cfg -Raw) -replace '', "$runtime" | Set-Content $cfg
+ Write-Host "injected $((($deps -split '').Count) - 1) binding redirect(s)"
+
+ $env:APPMAP_CONFIG_FILE = Join-Path $site "appmap.yml"
+ $env:APPMAP_OUTPUT_DIRECTORY = Join-Path $site "tmp\appmap"
+ $env:APPMAP_DEBUG = "true"
+
+ $exe = "C:\Program Files\IIS Express\iisexpress.exe"
+ if (-not (Test-Path $exe)) { $exe = "C:\Program Files (x86)\IIS Express\iisexpress.exe" }
+ if (-not (Test-Path $exe)) { throw "IIS Express not found on the runner" }
+
+ # Capture the host's output (agent debug + any FileLoadException).
+ $out = Join-Path $site "iisexpress.out.log"
+ $err = Join-Path $site "iisexpress.err.log"
+ $proc = Start-Process $exe -ArgumentList "/path:$site","/port:8088" -PassThru `
+ -RedirectStandardOutput $out -RedirectStandardError $err
+ try {
+ $served = $false
+ for ($i = 0; $i -lt 30; $i++) {
+ try {
+ (Invoke-WebRequest "http://localhost:8088/Default.aspx" -UseBasicParsing).Content | Out-Null
+ $served = $true; break
+ } catch { Start-Sleep -Seconds 1 }
+ }
+ Start-Sleep -Seconds 1 # let the end-request hook flush the map
+ } finally {
+ Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
+ }
+ Write-Host "--- iisexpress stdout ---"; Get-Content $out -ErrorAction SilentlyContinue
+ Write-Host "--- iisexpress stderr ---"; Get-Content $err -ErrorAction SilentlyContinue
+ Write-Host "--- appmap tree ---"; Get-ChildItem -Recurse (Join-Path $site "tmp") -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }
+ if (-not $served) { throw "IIS Express never served the app" }
+
+ - name: Assert the module produced a request AppMap
+ shell: pwsh
+ run: |
+ $dir = Join-Path $env:RUNNER_TEMP "site\tmp\appmap"
+ $maps = Get-ChildItem -Recurse $dir -Filter *.appmap.json -ErrorAction SilentlyContinue
+ if (-not $maps) { throw "no AppMap produced by the System.Web module" }
+ $json = Get-Content $maps[0].FullName -Raw | ConvertFrom-Json
+ $http = $json.events | Where-Object { $_.http_server_request } | Select-Object -First 1
+ if (-not $http) { throw "map has no http_server_request event" }
+ Write-Host "System.Web module recorded $($http.http_server_request.request_method) $($http.http_server_request.path_info)"
diff --git a/managed/.github/workflows/harness.yml b/managed/.github/workflows/harness.yml
new file mode 100644
index 0000000..7893dbf
--- /dev/null
+++ b/managed/.github/workflows/harness.yml
@@ -0,0 +1,84 @@
+name: Agent harness (eShopOnWeb)
+
+# Records Microsoft's unmodified eShopOnWeb reference app under the agent and
+# validates every AppMap with the official CLI plus coverage thresholds.
+# Heavier than the unit CI (clones + builds an external repo), so it runs on
+# demand and weekly, and on changes to the agent or the harness.
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "0 6 * * 1"
+ push:
+ paths:
+ - "src/**"
+ - "harness/**"
+ - ".github/workflows/harness.yml"
+
+jobs:
+ # Method + label coverage: record Microsoft's unmodified eShopOnWeb test
+ # suite under the agent.
+ eshoponweb:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install AppMap CLI
+ # --ignore-scripts skips fsevents' (macOS-only) native build.
+ run: npm install -g @appland/appmap --ignore-scripts
+ - name: Run harness against eShopOnWeb
+ run: python3 harness/run.py harness/targets/eshoponweb.json
+
+ # HTTP + SQL coverage: launch a web app that references no AppMap package
+ # and attach the agent purely through environment variables (the zero-touch
+ # HostingStartup), then assert per-request maps carry HTTP and SQL events.
+ zero-touch-web:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install AppMap CLI
+ run: npm install -g @appland/appmap --ignore-scripts
+ - name: Run harness against the zero-touch web fixture
+ # --determinism records twice: the first pass is the coverage check,
+ # the second asserts the maps are structurally identical (R5).
+ run: python3 harness/run.py harness/targets/zerotouch-web.json --determinism
+
+ # SQL capture against SQL Server (Microsoft.Data.SqlClient) — the provider
+ # whose partial type-load on Linux exposed the SqlHooks bug that SQLite
+ # never would. Regression-guards that fix end to end.
+ sql-server-web:
+ runs-on: ubuntu-latest
+ services:
+ mssql:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ env:
+ ACCEPT_EULA: "Y"
+ MSSQL_SA_PASSWORD: "Appmap!Harness2026"
+ ports:
+ - 1433:1433
+ env:
+ # The fixture reads this; the app retries until the container accepts
+ # connections, so no service health check is needed.
+ ConnectionStrings__Default: "Server=localhost,1433;Database=AppMapHarness;User Id=sa;Password=Appmap!Harness2026;TrustServerCertificate=True;Encrypt=False"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install AppMap CLI
+ run: npm install -g @appland/appmap --ignore-scripts
+ - name: Run harness against the SQL Server web fixture
+ run: python3 harness/run.py harness/targets/sqlserver-web.json
diff --git a/managed/.gitignore b/managed/.gitignore
new file mode 100644
index 0000000..0f4c71a
--- /dev/null
+++ b/managed/.gitignore
@@ -0,0 +1,13 @@
+bin/
+obj/
+*.user
+**/tmp/appmap/
+
+# SQLite runtime databases (PetClinic example)
+*.db
+*.db-shm
+*.db-wal
+
+# Python bytecode (harness tooling)
+__pycache__/
+*.pyc
diff --git a/managed/AppMap.sln b/managed/AppMap.sln
new file mode 100644
index 0000000..956dbd0
--- /dev/null
+++ b/managed/AppMap.sln
@@ -0,0 +1,84 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppMap.Agent", "src\AppMap.Agent\AppMap.Agent.csproj", "{6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A11}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppMap.StartupHook", "src\AppMap.StartupHook\AppMap.StartupHook.csproj", "{6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A12}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppMap.AspNetCore", "src\AppMap.AspNetCore\AppMap.AspNetCore.csproj", "{6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A13}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppMap.Testing.Xunit", "src\AppMap.Testing.Xunit\AppMap.Testing.Xunit.csproj", "{6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A14}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppMap.Testing.NUnit", "src\AppMap.Testing.NUnit\AppMap.Testing.NUnit.csproj", "{6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A15}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppMap.Agent.Tests", "test\AppMap.Agent.Tests\AppMap.Agent.Tests.csproj", "{6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A16}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HelloAppMap", "examples\HelloAppMap\HelloAppMap.csproj", "{6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A17}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{24212A48-1590-42F2-8A12-7A972CF0644B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PetClinic", "examples\PetClinic\PetClinic.csproj", "{95076417-54BC-4F31-A7FF-2F5A5380F5A9}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3B997D78-7003-4D00-8627-2200251B6C40}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppMap.Attributes", "src\AppMap.Attributes\AppMap.Attributes.csproj", "{2F012F7B-B729-4256-AECB-4AA7441113C8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppMap.SystemWeb", "src\AppMap.SystemWeb\AppMap.SystemWeb.csproj", "{3E63E126-93C9-4E74-B227-64AA8EE2C50F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A11}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A11}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A11}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A12}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A12}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A12}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A13}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A13}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A13}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A14}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A14}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A14}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A15}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A15}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A15}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A16}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A16}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A16}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A17}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A17}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B1F4E2A-9C3D-4A75-8E10-2F5B7D9C0A17}.Release|Any CPU.Build.0 = Release|Any CPU
+ {95076417-54BC-4F31-A7FF-2F5A5380F5A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {95076417-54BC-4F31-A7FF-2F5A5380F5A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {95076417-54BC-4F31-A7FF-2F5A5380F5A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {95076417-54BC-4F31-A7FF-2F5A5380F5A9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2F012F7B-B729-4256-AECB-4AA7441113C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2F012F7B-B729-4256-AECB-4AA7441113C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2F012F7B-B729-4256-AECB-4AA7441113C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2F012F7B-B729-4256-AECB-4AA7441113C8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3E63E126-93C9-4E74-B227-64AA8EE2C50F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3E63E126-93C9-4E74-B227-64AA8EE2C50F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3E63E126-93C9-4E74-B227-64AA8EE2C50F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3E63E126-93C9-4E74-B227-64AA8EE2C50F}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {95076417-54BC-4F31-A7FF-2F5A5380F5A9} = {24212A48-1590-42F2-8A12-7A972CF0644B}
+ {2F012F7B-B729-4256-AECB-4AA7441113C8} = {3B997D78-7003-4D00-8627-2200251B6C40}
+ {3E63E126-93C9-4E74-B227-64AA8EE2C50F} = {3B997D78-7003-4D00-8627-2200251B6C40}
+ EndGlobalSection
+EndGlobal
diff --git a/managed/BACKLOG.md b/managed/BACKLOG.md
new file mode 100644
index 0000000..57f19b3
--- /dev/null
+++ b/managed/BACKLOG.md
@@ -0,0 +1,128 @@
+# Backlog
+
+## 1. Deep agent test harness (parity with appmap-java's)
+
+Status: **shipped** in `harness/` — an app-agnostic, manifest-driven harness
+that records real, unmodified .NET apps under the agent and validates every
+map with the official CLI plus coverage thresholds. Two modes, both wired
+into `.github/workflows/harness.yml`:
+
+- **`tests` mode** — method + label coverage. Demonstrated against
+ Microsoft's eShopOnWeb (2 maps, ~1k events, 125 classMap functions,
+ `crypto.digest` label).
+- **`web` mode** — ✅ **HTTP + SQL coverage**, via the zero-touch
+ HostingStartup attach. Demonstrated against an in-repo fixture
+ (`fixtures/ZeroTouchWeb`) that references no AppMap package: 5 maps,
+ 5 `http_server_request`, 4 `sql_query`, full HTTP→method→SQL chains. The
+ agent attaches with only `DOTNET_STARTUP_HOOKS` +
+ `ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=AppMap.AspNetCore`.
+
+Remaining depth to add:
+
+- **Runtime code analysis (RCA-style) assertions**: plant a deliberate
+ N+1, an unauthenticated endpoint, a logged secret, and a
+ `BinaryFormatter.Deserialize`, then assert AppMap's analysis flags each.
+ Builds on the events + label taxonomy the harness already exercises.
+ (`@appland/scanner` provides the rules: `n-plus-one-query`, `secret-in-log`,
+ `deserialization-of-untrusted-data`, etc.)
+- **A large real app in `web` mode**: eShop/nopCommerce against a real
+ relational DB (Postgres/SQL Server), which needs container infra in CI.
+
+## 1a. Study gap-analysis follow-ups (R1–R8)
+
+From a live zero-touch run against eShopOnWeb on SQL Server 2022. Done:
+
+- ✅ **SqlHooks partial-load fix** — `Assembly.GetTypes()` threw
+ `ReflectionTypeLoadException` on `Microsoft.Data.SqlClient` (1 unloadable
+ type on Linux) and the wholesale catch discarded all 644 loadable types,
+ including `SqlCommand` → **0 SQL captured**. Now patches the loadable
+ subset (0 → 36 `sql_query` against SQL Server). SQLite-only harness is why
+ it slipped; **add a SQL Server web-mode target** to close the gap.
+- ✅ **R4 Gap A — relative source paths**. `SourceLocator` now emits paths
+ relative to the repo root with forward slashes; harness guards it.
+- ✅ **SQL Server `web` target** (`fixtures/SqlServerWeb` +
+ `targets/sqlserver-web.json` + a `sql-server-web` CI job with an mssql
+ service container). Regression-guards the SqlHooks fix against the actual
+ provider; the partial-load extraction is also unit-tested directly so the
+ guard holds even where the load doesn't fault.
+
+- ✅ **R4 Gap B — cross-platform acceptance test**. CI records a map on the
+ Windows runner, uploads it, and a Linux job (`cross-platform-query` in
+ `ci.yml`) indexes + queries it and asserts every source path resolves
+ against the Linux checkout. Proves record-on-Windows / query-on-Linux end
+ to end, unblocked by Gap A.
+
+- ✅ **R5 — determinism assertion**. `run.py --determinism` records a web
+ target twice and asserts the maps are structurally identical after dropping
+ volatile fields (ids, timestamps, elapsed, headers, object_ids, value
+ text); wired into the `zero-touch-web` CI job. Verified it catches a
+ planted structural change while ignoring elapsed/id churn.
+
+- ✅ **R1 — classic-ASP.NET `System.Web` smoke**. `fixtures/SystemWebApp`
+ (web.config-registered `IHttpModule`, no AppMap source reference) hosted on
+ IIS Express in the `system-web-iis` Windows CI job, asserting a per-request
+ AppMap with an `http_server_request` event — **green in CI**. Surfaced a
+ real deployment requirement: under .NET Framework the netstandard2.0 agent
+ needs binding redirects for its transitive `System.*` assemblies (the job
+ generates them; documented in the README).
+
+**All gap-analysis items R1–R8 are now done and CI-verified.** Next frontier:
+the RCA-findings depth (`@appland/scanner` over planted anti-patterns).
+
+Original scope notes (for the deeper passes):
+
+- **Read the appmap-java harness first** (`appmap-java`'s `agent/test`,
+ its Spring PetClinic smoke tests, and the BATS/CI integration jobs) and
+ match its coverage point for point: process/request/remote/test
+ recording, classMap correctness, label coverage, exception capture,
+ SQL capture, HTTP normalization.
+- **Clone a large public .NET application** (candidates:
+ `dotnet/eShop`, `nopSolutions/nopCommerce`, `OrchardCMS/OrchardCore`,
+ `abpframework/abp` samples) and run the agent against its full test
+ suite and seeded HTTP scenarios in CI.
+- **Validate output against the official tooling**, not just our own
+ serializer tests: `@appland/appmap` CLI `index`/`stats`/
+ `sequence-diagram` must accept every generated map (this caught the
+ `classMap` key bug); fail CI on validation errors.
+- **Runtime code analysis (RCA-style) assertions**: run AppMap's analysis
+ rules over the generated maps and assert expected findings appear —
+ e.g. introduce a deliberate N+1 query, an unauthenticated endpoint, a
+ logged secret, and a `BinaryFormatter.Deserialize` call in the fixture
+ app, then assert each is flagged. This exercises the label taxonomy
+ end to end the way AppMap's own scanner does.
+- **Matrix**: Debug/Release, with/without portable PDBs, xUnit + NUnit
+ recorders, SQLite + SQL Server providers, startup-hook vs
+ `UseAppMap()` attach.
+
+## 2. Older .NET support — DONE (validation on Windows outstanding)
+
+- ~~Multi-target `AppMap.Agent` to `net8.0;netstandard2.0`~~ done
+- ~~`AppMap.SystemWeb`: `IHttpModule` port of the middleware~~ done
+ (compiles against net472; needs a smoke test on a real IIS/Windows box)
+- ~~Document `AgentBootstrap.Init()` attach for .NET Framework~~ done
+- ~~Windows PDB fallback via diasymreader~~ done — best-effort COM
+ binder. Now exercised by the `windows` job in `.github/workflows/ci.yml`,
+ which builds HelloAppMap with `full` (a native
+ PDB) and asserts source locations resolve. Watch the first Windows CI
+ run; if the COM vtable layout is off, that job fails with "no source
+ locations" and needs a fix iteration on a real Windows box.
+- ~~Multi-target the xUnit/NUnit integrations to `net462`~~ done
+- Non-goals: NativeAOT / IL-trimmed apps (Harmony requires a JIT)
+
+## 3. Recording quality
+
+- ~~Streaming serialization to a temp file during recording~~ done,
+ with `eventUpdates` for post-hoc mutations (route templates)
+- ~~Bundled default excludes for common noise~~ done
+ (`APPMAP_DEFAULT_EXCLUDES`)
+- ~~Record async completions (real elapsed + unwrapped value)~~ done
+ (`APPMAP_RECORD_ASYNC`). Still future: split each `await` segment into
+ separate call frames (needs `MoveNext` state-machine instrumentation).
+- ~~More built-in labels~~ added `http.client.request` and XML
+ `deserialize`. `random.secure` and `crypto.sign/verify` were reverted:
+ their methods on the abstract BCL crypto bases
+ (`RandomNumberGenerator`, `AsymmetricAlgorithm`/`ECDsa`/`DSA`) are
+ intrinsic-backed and make Harmony throw `InvalidProgramException` at
+ patch time (caught, but noisy). Re-add them by targeting the concrete
+ algorithm types instead of the abstract base. Still wanted: `secret`,
+ `job.cancel`, `crypto.set_key`.
diff --git a/managed/DESIGN.md b/managed/DESIGN.md
new file mode 100644
index 0000000..47bfcf4
--- /dev/null
+++ b/managed/DESIGN.md
@@ -0,0 +1,267 @@
+# AppMap .NET Agent — Design Spec
+
+> Status: working prototype, validated end-to-end (unit tests + the official
+> `@appland/appmap` CLI + a real Microsoft reference app + a live web app) on
+> Linux and Windows CI. This document is written for review and for a possible
+> move into the AppMap org repo (`getappmap/appmap-dotnet`).
+
+## 1. Purpose
+
+A runtime agent that records the execution of a .NET application into the
+[AppMap JSON](https://github.com/getappmap/appmap) format — the .NET
+counterpart of `appmap-java`. It produces the same artifact (events +
+classMap + metadata) the rest of the AppMap toolchain already consumes
+(CLI, analysis rules, sequence diagrams, the VS Code/IntelliJ extensions).
+
+The design goal throughout is **parity of behaviour with `appmap-java`**, so
+that someone who knows the Java agent can predict what this one does. Where a
+mechanism has no .NET equivalent, the closest idiomatic one was chosen and
+the trade-off documented.
+
+### Goals
+- Emit AppMaps the official `@appland/appmap` CLI accepts unmodified.
+- Record the four `appmap-java` modes: process, request, remote, test.
+- Instrument by `appmap.yml` package config, with built-in framework hooks
+ and `[Labels]` attributes feeding the analysis label taxonomy.
+- Attach to an application with **no source changes** (parity with
+ `-javaagent`).
+- Support modern .NET (net8.0) and legacy (.NET Framework 4.6.2+ via
+ netstandard2.0).
+
+### Non-goals (current)
+- NativeAOT / IL-trimmed apps (Harmony needs a JIT).
+- Splitting an `async` method body into per-`await` frames (see §11).
+- Bytecode-level rewrite at load time (we patch at runtime instead — §4).
+
+## 2. Dependencies & licensing
+
+| Dependency | Use | License |
+|---|---|---|
+| [Lib.Harmony](https://github.com/pardeike/Harmony) 2.3.3 | runtime method patching | MIT |
+| YamlDotNet 15.1.2 | `appmap.yml` parsing | MIT |
+| System.Text.Json / System.Reflection.Metadata | output + PDB reading (netstandard2.0 only; inbox on net8.0) | MIT |
+
+The agent itself is `PackageLicenseExpression = MIT`. No GPL/LGPL in the
+dependency graph. Harmony is the only non-trivial third-party runtime
+dependency and is the main thing to vet (see §12).
+
+## 3. Project layout
+
+12 projects. Each maps to an `appmap-java` concept:
+
+| Project | Responsibility | `appmap-java` analog |
+|---|---|---|
+| `AppMap.Agent` | Core: config, instrumentation, recorder, output, source locations | `com.appland.appmap.*` |
+| `AppMap.Attributes` | Dependency-free `[Labels]` attribute | `@Labels` |
+| `AppMap.StartupHook` | `DOTNET_STARTUP_HOOKS` entry point | `premain` |
+| `AppMap.AspNetCore` | HTTP middleware, remote-recording endpoints, **zero-touch HostingStartup** | servlet filter |
+| `AppMap.SystemWeb` | `IHttpModule` for classic ASP.NET (.NET Framework) | servlet filter (legacy) |
+| `AppMap.Testing.Xunit` / `.NUnit` | `[AppMap]` per-test recording | JUnit integration |
+| `AppMap.Agent.Tests` | unit tests (42) | agent unit tests |
+| `harness/` (Python + fixture) | record real apps, validate with the CLI | `appmap-java`'s integration harness |
+
+## 4. Instrumentation
+
+**Mechanism.** Where the Java agent rewrites bytecode with Javassist at
+class-load time, this agent patches methods at runtime with Harmony: a
+**prefix** emits the `call` event; a **finalizer** (runs on both normal and
+exceptional return) emits the matching `return` event with `parent_id`,
+`elapsed`, and `return_value` or `exceptions`.
+
+**Why runtime patching, not a profiler/ICorProfiler.** A profiler-based
+IL-rewriter would be more powerful but is a native component per-architecture
+and a much larger surface. Harmony gives load-time-agnostic patching in pure
+managed code, multi-targets to .NET Framework, and is what the original
+prototype reached for. The cost is the set of BCL methods Harmony can't
+patch (intrinsics, `[RequiresDynamicCode]`), handled explicitly below.
+
+**Selection pipeline** (`Instrumentor`):
+1. `AppMapConfig` parses `appmap.yml` → package prefixes, exclude lists,
+ explicit `methods:` rules with labels.
+2. On assembly load (and for already-loaded assemblies), each candidate
+ type's methods are matched against the config.
+3. Matched methods are patched via `HookPatcher`, which registers a
+ per-method `EventTemplate` (defined_class, method_id, static, params,
+ labels, source location) **at patch time** so the hot path only fills in
+ values — the same "compute once" strategy as the Java agent.
+
+**Robustness (the part worth scrutinising).** Real BCL usage in eShopOnWeb
+surfaced that Harmony throws `InvalidProgramException` when asked to patch
+certain methods (JIT intrinsics like `RandomNumberGenerator.GetBytes`,
+`[RequiresDynamicCode]` helpers like `JsonSerializer.Deserialize`,
+abstract-base crypto signatures). The agent:
+- Skips methods with no IL body and open generics up front.
+- Wraps every `harmony.Patch` in a try/catch: a failure leaves the method
+ uninstrumented (no corruption — the exception is at patch time, before any
+ IL is applied) and logs a calm "skipping (not instrumentable)" at debug.
+- Built-in hook rules that proved consistently un-patchable were removed
+ rather than left to spam (see `BuiltinHooks` comments + `BACKLOG.md`).
+
+Harmony's robustness on arbitrary BCL methods is the historical worry with
+this approach. The current design is defensive-by-default: patch failures are
+contained per-method, and **the agent must never prevent the host app from
+starting** (`AgentBootstrap` wraps the whole init in a catch).
+
+**SQL** (`SqlHooks`). Every concrete `System.Data.Common.DbCommand`
+implementation found in loaded provider assemblies has its `Execute*` /
+`Execute*Async` overrides patched to emit `sql_query` events — the analog of
+the JDBC `Statement` hooks. New provider assemblies are caught via an
+`AssemblyLoad` handler. `database_type` is inferred from the provider type
+name (ADO.NET has no portable `DatabaseProductName`).
+
+**Built-in framework hooks** (`BuiltinHooks`). Pre-labeled rules for logging,
+auth, crypto, deserialization, HTTP client, session, Hangfire — recorded
+with labels even outside the user's packages. Interface/base-class rules are
+resolved against concrete implementations as assemblies load (open generics
+can't be patched, so e.g. `SignInManager` is covered via the non-generic
+`IAuthenticationService` beneath it). Full table in `README.md` §Labels.
+
+## 5. Recording model (`Recorder`, `Recording`)
+
+A singleton `Recorder` holds one optional **global** session plus an
+`AsyncLocal` **request** session. The Java agent uses `ThreadLocal`;
+`AsyncLocal` is the .NET fix for "a recording must follow its request across
+`await`". Events stream to a temp file as they happen, so memory doesn't grow
+with recording length (as in the Java agent). Events mutated after being
+streamed — the HTTP route template, known only after routing — are emitted in
+the spec's `eventUpdates` section.
+
+Four modes (all `appmap-java` parity):
+- **Process** — `APPMAP_RECORD_PROCESS=true`, written at process exit.
+- **Request** — one AppMap per HTTP request (`AppMapMiddleware`).
+- **Remote** — `GET/POST/DELETE /_appmap/record` (`RemoteRecordingMiddleware`).
+- **Test** — `[AppMap]` on xUnit/NUnit classes, one map per test.
+
+## 6. Async handling
+
+When `APPMAP_RECORD_ASYNC=true` (default), an `async` method whose return
+type is a `Task`/`ValueTask` records its `return` event when the returned
+task **completes**, not when the task is handed back — so `elapsed` is the
+real wall-clock duration and `return_value` is the unwrapped result. Implemented
+in `AsyncResult` + the method finalizer attaching a continuation. (Not yet
+done: splitting the post-`await` resumption into separate frames — §11.)
+
+## 7. Output & CLI compatibility
+
+`AppMapSerializer` writes the AppMap document: `version`, `metadata`
+(language, client, git via `GitMetadata`, recorder), `classMap`, `events`.
+classMap is built from events only (functions that produced no event are
+omitted) with namespace→`package`, nested type→`class`, same as Java.
+
+**Compatibility is enforced, not assumed.** The harness runs
+`appmap sequence-diagram` over every generated map and fails if the official
+CLI rejects it. (This is what caught an early `class_map` vs `classMap`
+casing bug.)
+
+## 8. Zero-touch attach (HostingStartup) — newest piece
+
+Goal: attach to an **unmodified** ASP.NET Core app — no package reference, no
+`app.UseAppMap()` — matching the `-javaagent` experience.
+
+```
+DOTNET_STARTUP_HOOKS=…/AppMap.StartupHook.dll # instrumentation (method + SQL)
+ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=AppMap.AspNetCore # middleware auto-registration
+```
+
+How it works:
+1. `AppMap.AspNetCore` carries `[assembly: HostingStartup(typeof(AppMapHostingStartup))]`.
+ ASP.NET Core loads any assembly named in `ASPNETCORE_HOSTINGSTARTUPASSEMBLIES`
+ and runs its `IHostingStartup.Configure` before the app's own startup.
+2. `AppMapHostingStartup` registers an `IStartupFilter` that prepends
+ `app.UseAppMap()` to the pipeline — so the middleware brackets the whole
+ request, exactly as a manual first-line `UseAppMap()` would.
+3. **Assembly resolution.** The app has no reference to `AppMap.AspNetCore`,
+ so ASP.NET Core's `Assembly.Load("AppMap.AspNetCore")` would normally
+ fail. The startup hook (already loaded, very early) installs an
+ `AssemblyResolve` handler that serves AppMap assemblies from the agent
+ directory. Because the hook ships `AppMap.AspNetCore.dll` alongside itself,
+ one directory is the entire agent deployment.
+4. `UseAppMap()` is idempotent (guards via `app.Properties`) so the
+ HostingStartup and a hand-written call can't double-register.
+
+This is the .NET-idiomatic equivalent of an APM agent's auto-instrumentation,
+using only first-class framework extension points (no IL injection into the
+app, no profiler).
+
+## 9. Source locations
+
+`SourceLocator` reads method sequence points from the **portable PDB** via
+`System.Reflection.Metadata` to populate `path`/`lineno` on events and
+classMap. Build with `portable`. Classic **Windows
+PDBs** fall back to the native `diasymreader` COM binder
+(`WindowsPdbReader`, Windows-only, best-effort) — this COM interop path is
+validated on a real Windows runner in CI (it resolves locations from a native
+`full` PDB; the assertion is hard-failing).
+
+PDBs embed the **absolute build-machine path**, so `SourceLocator` relativizes
+every path against the repo root (git root, then the appmap.yml directory) and
+normalizes to forward slashes — like `appmap-java`. Without this a map
+recorded on Windows (`C:\agent\work\repo\src\X.cs`) would not resolve against
+the same repo checked out on Linux; with it, both sides see
+`src/X.cs`. The harness asserts no map carries an absolute or backslash path.
+
+## 10. Test harness (`harness/`)
+
+App-agnostic, manifest-driven. Records real apps under the agent and
+validates every map with the official CLI plus coverage thresholds. Two modes:
+
+- **`tests` mode** — clone a repo, run its test suite with the agent attached
+ (process recording, scoped by namespace). Method + label coverage.
+ Demonstrated target: **Microsoft's eShopOnWeb**, unmodified →
+ **2 maps, ~1020 events, 125 classMap functions, `crypto.digest` label**,
+ all CLI-valid.
+- **`web` mode** — build a web app, launch it with the zero-touch attach
+ (env vars only), drive HTTP requests, assert HTTP + SQL coverage.
+ Demonstrated target: `fixtures/ZeroTouchWeb`, an ASP.NET Core + EF
+ Core/SQLite app **with no AppMap reference** → **5 maps, 5
+ `http_server_request`, 4 `sql_query`**, capturing full HTTP→method→SQL
+ chains (`GET /widgets` → `WidgetService.All` → SQLite `SELECT`).
+
+Both run as jobs in `.github/workflows/harness.yml`.
+
+## 11. Known limitations (honest list)
+
+- `async` records the `return` at task completion, but post-`await`
+ resumption is not split into separate call frames.
+- Open generic methods/types are not patched (Harmony limitation).
+- xUnit's `BeforeAfterTestAttribute` doesn't expose the test outcome, so
+ `test_status` is always "succeeded" there; NUnit reports real outcomes.
+- `database_type` is inferred from the provider type name.
+- `random.secure` / `crypto.sign/verify` labels are deferred: their methods
+ on abstract BCL crypto bases are intrinsic-backed and currently un-patchable
+ (need concrete-type targeting). See `BACKLOG.md`.
+- NativeAOT / full IL-trimming unsupported.
+
+## 12. Review guide — what to scrutinise
+
+For a code review, the load-bearing / highest-risk areas, in order:
+
+1. **`Instrumentation/HookPatcher.cs` + `MethodHooks.cs`** — the Harmony
+ prefix/finalizer, `__state` threading, re-entrancy guard (`inHook`),
+ exception-path correctness. The hot path's allocation/locking.
+2. **`Instrumentation/BuiltinHooks.cs` + `SqlHooks.cs`** — which BCL methods
+ are patched, and the graceful-skip behaviour for un-patchable ones. The
+ `AssemblyLoad` scanning cost on large apps.
+3. **`Record/Recorder.cs` + `Recording.cs`** — thread-safety of the global
+ vs `AsyncLocal` sessions, the streaming writer, `eventUpdates`.
+4. **`Util/SourceLocator.cs` + `WindowsPdbReader.cs`** — the PDB readers,
+ especially the hand-rolled diasymreader COM vtable.
+5. **Zero-touch attach** (`AppMap.AspNetCore/AppMapHostingStartup.cs`,
+ `StartupHook/StartupHook.cs`) — assembly-resolution correctness and the
+ idempotency guard.
+6. **Value capture** (`Output/Value.cs`) — stringification limits, cycles,
+ and not triggering side effects in user `ToString()`.
+
+## 13. Porting notes (to `getappmap/appmap-dotnet`)
+
+- The csproj already declares `PackageId=AppMap.Agent`, `MIT`, and
+ `RepositoryUrl=https://github.com/getappmap/appmap-dotnet` — it's structured
+ to drop in.
+- The harness's Python orchestrator + the eShopOnWeb/ZeroTouchWeb targets are
+ self-contained and could become the integration-test job.
+- Things to harden before shipping: a broader BCL-patchability denylist
+ (driven by running against more real apps), perf benchmarking of the hot
+ path under load, and a decision on whether to invest in an
+ `ICorProfiler`-based rewriter for the cases Harmony can't reach.
+- Test coverage today is 42 unit tests + 2 CI integration jobs; a port should
+ expand unit coverage around the Recorder concurrency and value capture.
diff --git a/managed/README.md b/managed/README.md
new file mode 100644
index 0000000..f1f5bb4
--- /dev/null
+++ b/managed/README.md
@@ -0,0 +1,263 @@
+# AppMap agent for .NET
+
+Records the execution of .NET code as [AppMap](https://appmap.io) JSON
+files (format version 1.2), the same format produced by
+[appmap-java](https://github.com/getappmap/appmap-java), whose
+architecture this agent ports to C#.
+
+This is a fully managed agent. The earlier
+[appmap-dotnet](https://github.com/getappmap/appmap-dotnet) prototype
+instrumented IL from a C++ CLR-profiler plugin (via the CLR
+Instrumentation Engine), which made it Linux-only and hard to evolve.
+This implementation instead mirrors what appmap-java does on the JVM —
+a managed agent that rewrites methods at runtime — using
+[Harmony](https://github.com/pardeike/Harmony) where the Java agent uses
+Javassist.
+
+## Layout
+
+| Project | Role | appmap-java counterpart |
+|---|---|---|
+| `src/AppMap.Agent` | Config, recorder, event model, serializer, Harmony instrumentation, SQL + built-in hooks | `agent` (config / record / output / transform) |
+| `src/AppMap.Attributes` | `[Labels]` attribute for application code (dependency-free) | annotation artifact (`@Labels`) |
+| `src/AppMap.StartupHook` | `DOTNET_STARTUP_HOOKS` entry point | `premain` |
+| `src/AppMap.AspNetCore` | HTTP server events, request recording, remote recording endpoints | servlet hooks, `RemoteRecordingManager` |
+| `src/AppMap.SystemWeb` | The same for classic ASP.NET (`IHttpModule`, .NET Framework) | servlet hooks |
+| `src/AppMap.Testing.Xunit` | One AppMap per xUnit test | JUnit hooks |
+| `src/AppMap.Testing.NUnit` | One AppMap per NUnit test (with test_status) | TestNG hooks |
+| `test/AppMap.Agent.Tests` | Serializer / config / value-capture unit tests | — |
+| `examples/HelloAppMap` | Smallest possible recorded app | — |
+| `examples/PetClinic` | ASP.NET Core + EF Core/SQLite web app (HTTP + SQL) | spring-petclinic |
+| `harness/` | Records an unmodified real app (eShopOnWeb) and CLI-validates the maps | agent integration tests |
+
+## Quick start
+
+Build everything:
+
+```sh
+dotnet build AppMap.sln
+```
+
+Create `appmap.yml` in your project root, listing the namespaces to record:
+
+```yaml
+name: my-app
+packages:
+- path: MyApp
+ exclude:
+ - MyApp.Generated
+```
+
+### Console / worker process
+
+```sh
+APPMAP_RECORD_PROCESS=true \
+DOTNET_STARTUP_HOOKS=/path/to/AppMap.StartupHook.dll \
+dotnet run
+```
+
+One AppMap covering the whole process is written to
+`tmp/appmap/process_recording/` at exit.
+
+### ASP.NET Core
+
+```csharp
+app.UseAppMap(); // first in the pipeline
+```
+
+Or attach with **zero source changes** — no package reference, no
+`UseAppMap()` call — by naming the integration assembly as a HostingStartup:
+
+```sh
+DOTNET_STARTUP_HOOKS=/path/to/AppMap.StartupHook.dll \
+ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=AppMap.AspNetCore \
+dotnet YourApp.dll
+```
+
+`AppMap.AspNetCore` ships an `[assembly: HostingStartup]` that registers an
+`IStartupFilter` prepending `UseAppMap()` for you — the .NET analog of a Java
+`-javaagent` auto-registering its servlet filter. (See
+`harness/fixtures/ZeroTouchWeb` for a real app recorded this way.)
+
+Either way this records `http_server_request`/`http_server_response` events,
+writes one AppMap per request to `tmp/appmap/request_recording/` (disable
+with `APPMAP_RECORDING_REQUESTS=false`), and serves the remote-recording
+protocol used by AppMap clients:
+
+- `GET /_appmap/record` → `{"enabled": }`
+- `POST /_appmap/record` → start (409 if already recording)
+- `DELETE /_appmap/record` → stop; response body is the AppMap JSON
+- `GET /_appmap/record/checkpoint` → snapshot without stopping
+
+### Tests
+
+```csharp
+[AppMap] // AppMap.Testing.Xunit or AppMap.Testing.NUnit
+public class UserServiceTests { ... }
+```
+
+AppMaps land in `tmp/appmap/xunit/` / `tmp/appmap/nunit/`, named
+`{Class}_{method}.appmap.json`. Disable test parallelization while
+recording, or maps from concurrent tests will interleave (the old
+prototype had the same constraint with XUnit).
+
+### SQL
+
+Any ADO.NET provider whose command derives from
+`System.Data.Common.DbCommand` (SqlClient, Npgsql, Sqlite, MySql,
+Oracle) is hooked automatically; `Execute*` calls appear as `sql_query`
+events.
+
+### .NET Framework / classic ASP.NET
+
+`AppMap.Agent` multi-targets `net8.0` and `netstandard2.0`, so it also
+runs on .NET Framework 4.6.2+, .NET Core 2.x+, and Mono.
+`DOTNET_STARTUP_HOOKS` is a .NET Core 3.0+ feature; on .NET Framework
+call `AppMap.AgentBootstrap.Init()` explicitly at startup (e.g. from
+`Application_Start`), or just register the module below, which does it
+for you. For classic ASP.NET, `AppMap.SystemWeb` provides the
+`IHttpModule` equivalent of `UseAppMap()`:
+
+```xml
+
+
+
+
+
+```
+
+Because the agent is netstandard2.0, a .NET Framework host needs **binding
+redirects** for its transitive `System.*` package assemblies (e.g.
+`System.Text.Json`, `System.Memory`) — otherwise they throw
+`FileLoadException` and the module records nothing. A project reference with
+`true` produces
+them; the `system-web-iis` CI job shows generating them from the deployed
+assemblies for a website. (This requirement is exercised end-to-end on a
+real IIS Express host in CI.)
+
+Build with `portable` (supported since VS2017) for
+source locations; classic Windows PDBs are read best-effort through the
+native diasymreader binder on Windows. NativeAOT and IL-trimmed apps are
+out of scope (Harmony requires a JIT).
+
+## Configuration
+
+`appmap.yml` is searched from the current directory upward
+(`APPMAP_CONFIG_FILE` overrides). Schema, as in appmap-java:
+
+```yaml
+name: my-app # metadata.app
+appmap_dir: tmp/appmap
+packages:
+- path: MyApp.Services # namespace prefix to instrument
+ exclude: # fully-qualified-name prefixes to skip
+ - MyApp.Services.Internal
+- path: MyApp.Domain
+ methods: # alternative: explicit allow-list with labels
+ - class: .*Repository
+ name: (Find|Save).*
+ labels: [crud]
+```
+
+Environment variables (defaults in parentheses):
+
+- `APPMAP_OUTPUT_DIRECTORY` — overrides `appmap_dir`
+- `APPMAP_RECORDING_REQUESTS` (true) — per-request AppMaps
+- `APPMAP_RECORDING_REMOTE` (true) — `/_appmap/record` endpoints
+- `APPMAP_RECORD_PROCESS` (false) — whole-process recording
+- `APPMAP_RECORD_PRIVATE` (false) — instrument non-public methods
+- `APPMAP_RECORD_ASYNC` (true) — emit an async method's return when its
+ Task completes (real elapsed and unwrapped value) rather than when the
+ Task is returned
+- `APPMAP_EVENT_VALUESIZE` (1024) — max captured value length
+- `APPMAP_EVENT_DISABLEVALUE` (false) — never stringify values
+- `APPMAP_DEFAULT_EXCLUDES` (true) — skip noise methods (`Equals`,
+ `GetHashCode`, `ToString`, `CompareTo`, `Deconstruct`, `Finalize`,
+ parameterless `Dispose`) and EF Core `*.Migrations` namespaces
+- `APPMAP_DEBUG` (false) — agent diagnostics on stderr
+- `APPMAP_DEBUG_DISABLEGIT` (false) — skip git metadata
+
+## Labels
+
+Labels on classMap functions are what AppMap runtime analysis rules match
+on. They come from three sources, merged per method:
+
+1. **Built-in framework hooks** — the analog of appmap-java's bundled,
+ pre-labeled hooks. These methods are recorded (with labels) even though
+ they are outside your packages: configuration:
+
+ | Framework code | Label |
+ |---|---|
+ | `LoggerExtensions.Log*` (Microsoft.Extensions.Logging) | `log` |
+ | `IAuthenticationService.AuthenticateAsync/SignInAsync/SignOutAsync` | `security.authentication` |
+ | `IAuthorizationService.AuthorizeAsync` | `security.authorization` |
+ | `SymmetricAlgorithm.CreateEncryptor` / `CreateDecryptor` | `crypto.encrypt` / `crypto.decrypt` |
+ | `HashAlgorithm.ComputeHash[Async]` | `crypto.digest` |
+ | `BinaryFormatter.Deserialize` | `deserialize.unsafe` |
+ | `JsonSerializer.Deserialize`, `JsonConvert.DeserializeObject`, `XmlSerializer.Deserialize`, `DataContractSerializer.ReadObject` | `deserialize` |
+ | `HttpClient.Send/SendAsync` | `http.client.request` |
+ | `ISession.TryGetValue` / `Set`, `Remove`, `Clear` | `http.session.read` / `http.session.write` |
+ | `IBackgroundJobClient.Create` (Hangfire) | `job.create` |
+
+ Interface- and base-class-based rules are resolved against concrete
+ implementations as assemblies load (open generics cannot be patched, so
+ e.g. `SignInManager` is covered via the non-generic
+ `IAuthenticationService` beneath it).
+
+2. **`[AppMap.Labels(...)]`** from the dependency-free `AppMap.Attributes`
+ package — the analog of `@Labels`. On a method or a class:
+
+ ```csharp
+ using AppMap;
+
+ [Labels("crud")]
+ public Owner Add(Owner owner) { ... }
+ ```
+
+ A labeled method is recorded even when its namespace is not listed under
+ packages:. The attribute is matched by full type name
+ (`AppMap.LabelsAttribute`), not assembly identity.
+
+3. **appmap.yml `methods:` entries** (see Configuration above).
+
+## How it maps to appmap-java
+
+- **Instrumentation.** Where the Java agent rewrites bytecode with
+ Javassist at class-load time, this agent patches methods at runtime
+ with Harmony: a prefix emits the `call` event, a finalizer (which runs
+ on both normal and exceptional exit) emits the matching `return` event
+ with `parent_id`, `elapsed`, `return_value` or `exceptions`.
+ `EventTemplateRegistry` caches per-method facts at patch time so the
+ hot path is cheap, like its Java namesake.
+- **Recorder.** Singleton with one optional global session plus an
+ `AsyncLocal` session for request recording (the Java agent uses
+ `ThreadLocal`; `AsyncLocal` lets a recording follow its request across
+ `await`). Events are streamed to a temp file as they happen, as the
+ Java agent does, so memory does not grow with recording length; events
+ mutated after being streamed (the HTTP route template, known only
+ after routing) are emitted in the spec's `eventUpdates` section.
+- **Source locations.** The Java agent reads `LineNumberTable` from
+ bytecode; this agent reads sequence points from the portable PDB
+ (`SourceLocator`), so build with `portable` to
+ get `path`/`lineno` in events and the class map. Classic Windows PDBs
+ fall back to the native diasymreader binder (Windows only,
+ best-effort).
+- **classMap.** Namespace segments become `package` nodes, nested types
+ become nested `class` nodes, and only functions that produced events
+ are included — same as the Java agent.
+
+## Known limitations
+
+- `async` methods record their `return` when the awaited `Task` completes
+ (real `elapsed` and unwrapped value), but the body's resumption after
+ each `await` is not yet split into separate call frames.
+- Open generic methods/types are not patched (Harmony limitation).
+- xUnit's `BeforeAfterTestAttribute` does not expose the test outcome,
+ so `test_status` is always "succeeded" there; the NUnit integration
+ reports real outcomes.
+- `database_type` is inferred from the provider's type name; ADO.NET has
+ no portable equivalent of JDBC's `DatabaseProductName`.
+- Methods inlined by the JIT before patching, and code compiled with
+ `[MethodImpl(MethodImplOptions.AggressiveInlining)]`, may be missed if
+ assemblies are loaded and JIT-compiled before the agent initializes —
+ prefer the startup hook over late `AgentBootstrap.Init()` calls.
diff --git a/managed/examples/HelloAppMap/HelloAppMap.csproj b/managed/examples/HelloAppMap/HelloAppMap.csproj
new file mode 100644
index 0000000..354eaf7
--- /dev/null
+++ b/managed/examples/HelloAppMap/HelloAppMap.csproj
@@ -0,0 +1,12 @@
+
+
+
+ Exe
+ net8.0
+ latest
+ enable
+ enable
+ portable
+
+
+
diff --git a/managed/examples/HelloAppMap/Program.cs b/managed/examples/HelloAppMap/Program.cs
new file mode 100644
index 0000000..0812736
--- /dev/null
+++ b/managed/examples/HelloAppMap/Program.cs
@@ -0,0 +1,27 @@
+using HelloAppMap;
+
+// Run with the agent attached (see README):
+// APPMAP_RECORD_PROCESS=true \
+// DOTNET_STARTUP_HOOKS=$PWD/../../src/AppMap.StartupHook/bin/Debug/net8.0/AppMap.StartupHook.dll \
+// dotnet run
+// The AppMap is written to tmp/appmap/process_recording/ on exit.
+
+var greeter = new Greeter();
+Console.WriteLine(greeter.Greet("AppMap"));
+Console.WriteLine(Calculator.Fibonacci(10));
+
+namespace HelloAppMap
+{
+ public class Greeter
+ {
+ public string Greet(string name) => $"Hello, {Decorate(name)}!";
+
+ public string Decorate(string name) => $"*{name}*";
+ }
+
+ public static class Calculator
+ {
+ public static int Fibonacci(int n) =>
+ n < 2 ? n : Fibonacci(n - 1) + Fibonacci(n - 2);
+ }
+}
diff --git a/managed/examples/HelloAppMap/appmap.yml b/managed/examples/HelloAppMap/appmap.yml
new file mode 100644
index 0000000..b0e55fb
--- /dev/null
+++ b/managed/examples/HelloAppMap/appmap.yml
@@ -0,0 +1,3 @@
+name: hello-appmap
+packages:
+- path: HelloAppMap
diff --git a/managed/examples/PetClinic/Controllers/OwnersController.cs b/managed/examples/PetClinic/Controllers/OwnersController.cs
new file mode 100644
index 0000000..6b6770b
--- /dev/null
+++ b/managed/examples/PetClinic/Controllers/OwnersController.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Mvc;
+using PetClinic.Models;
+using PetClinic.Services;
+
+namespace PetClinic.Controllers;
+
+[ApiController]
+[Route("owners")]
+public class OwnersController : ControllerBase
+{
+ private readonly OwnerService owners;
+
+ public OwnersController(OwnerService owners) => this.owners = owners;
+
+ [HttpGet]
+ public IEnumerable List() => owners.FindAll();
+
+ [HttpGet("{lastName}")]
+ public ActionResult Find(string lastName)
+ {
+ var owner = owners.FindByLastName(lastName);
+ return owner == null ? NotFound() : owner;
+ }
+
+ [HttpPost]
+ public Owner Create(Owner owner) => owners.Add(owner);
+}
diff --git a/managed/examples/PetClinic/Controllers/VetsController.cs b/managed/examples/PetClinic/Controllers/VetsController.cs
new file mode 100644
index 0000000..514a265
--- /dev/null
+++ b/managed/examples/PetClinic/Controllers/VetsController.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Mvc;
+using PetClinic.Models;
+using PetClinic.Services;
+
+namespace PetClinic.Controllers;
+
+[ApiController]
+[Route("vets")]
+public class VetsController : ControllerBase
+{
+ private readonly VetService vets;
+
+ public VetsController(VetService vets) => this.vets = vets;
+
+ [HttpGet]
+ public IEnumerable List() => vets.FindAll();
+
+ [HttpGet("async")]
+ public async Task> ListAsync() => await vets.FindAllAsync();
+}
diff --git a/managed/examples/PetClinic/Data/PetClinicContext.cs b/managed/examples/PetClinic/Data/PetClinicContext.cs
new file mode 100644
index 0000000..7b9ece4
--- /dev/null
+++ b/managed/examples/PetClinic/Data/PetClinicContext.cs
@@ -0,0 +1,13 @@
+using Microsoft.EntityFrameworkCore;
+using PetClinic.Models;
+
+namespace PetClinic.Data;
+
+public class PetClinicContext : DbContext
+{
+ public PetClinicContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Owners => Set();
+ public DbSet Pets => Set();
+ public DbSet Vets => Set();
+}
diff --git a/managed/examples/PetClinic/Data/SeedData.cs b/managed/examples/PetClinic/Data/SeedData.cs
new file mode 100644
index 0000000..be5139e
--- /dev/null
+++ b/managed/examples/PetClinic/Data/SeedData.cs
@@ -0,0 +1,34 @@
+using PetClinic.Models;
+
+namespace PetClinic.Data;
+
+public static class SeedData
+{
+ public static void Initialize(PetClinicContext context)
+ {
+ if (context.Owners.Any())
+ return;
+
+ var george = new Owner
+ {
+ FirstName = "George", LastName = "Franklin", City = "Madison",
+ Pets = { new Pet { Name = "Leo", Type = "cat" } },
+ };
+ var betty = new Owner
+ {
+ FirstName = "Betty", LastName = "Davis", City = "Sun Prairie",
+ Pets =
+ {
+ new Pet { Name = "Basil", Type = "hamster" },
+ new Pet { Name = "Rosy", Type = "dog" },
+ },
+ };
+ context.Owners.AddRange(george, betty);
+
+ context.Vets.AddRange(
+ new Vet { FirstName = "James", LastName = "Carter", Specialty = "general" },
+ new Vet { FirstName = "Helen", LastName = "Leary", Specialty = "radiology" });
+
+ context.SaveChanges();
+ }
+}
diff --git a/managed/examples/PetClinic/Models/Owner.cs b/managed/examples/PetClinic/Models/Owner.cs
new file mode 100644
index 0000000..c505475
--- /dev/null
+++ b/managed/examples/PetClinic/Models/Owner.cs
@@ -0,0 +1,10 @@
+namespace PetClinic.Models;
+
+public class Owner
+{
+ public int Id { get; set; }
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+ public string City { get; set; } = "";
+ public List Pets { get; set; } = new();
+}
diff --git a/managed/examples/PetClinic/Models/Pet.cs b/managed/examples/PetClinic/Models/Pet.cs
new file mode 100644
index 0000000..733c84a
--- /dev/null
+++ b/managed/examples/PetClinic/Models/Pet.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace PetClinic.Models;
+
+public class Pet
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public string Type { get; set; } = "";
+ public int OwnerId { get; set; }
+
+ [JsonIgnore] // break the Owner <-> Pet cycle when serializing responses
+ public Owner? Owner { get; set; }
+}
diff --git a/managed/examples/PetClinic/Models/Vet.cs b/managed/examples/PetClinic/Models/Vet.cs
new file mode 100644
index 0000000..4287d21
--- /dev/null
+++ b/managed/examples/PetClinic/Models/Vet.cs
@@ -0,0 +1,9 @@
+namespace PetClinic.Models;
+
+public class Vet
+{
+ public int Id { get; set; }
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+ public string Specialty { get; set; } = "";
+}
diff --git a/managed/examples/PetClinic/PetClinic.csproj b/managed/examples/PetClinic/PetClinic.csproj
new file mode 100644
index 0000000..5175c4e
--- /dev/null
+++ b/managed/examples/PetClinic/PetClinic.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ portable
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/managed/examples/PetClinic/Program.cs b/managed/examples/PetClinic/Program.cs
new file mode 100644
index 0000000..e942aff
--- /dev/null
+++ b/managed/examples/PetClinic/Program.cs
@@ -0,0 +1,27 @@
+using AppMap.AspNetCore;
+using Microsoft.EntityFrameworkCore;
+using PetClinic.Data;
+using PetClinic.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddControllers();
+builder.Services.AddDbContext(options =>
+ options.UseSqlite("Data Source=petclinic.db"));
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+app.UseAppMap(); // first in the pipeline: HTTP events + /_appmap/record
+
+using (var scope = app.Services.CreateScope())
+{
+ var context = scope.ServiceProvider.GetRequiredService();
+ context.Database.EnsureCreated();
+ SeedData.Initialize(context);
+}
+
+app.MapControllers();
+
+app.Run();
diff --git a/managed/examples/PetClinic/Services/OwnerService.cs b/managed/examples/PetClinic/Services/OwnerService.cs
new file mode 100644
index 0000000..f41415b
--- /dev/null
+++ b/managed/examples/PetClinic/Services/OwnerService.cs
@@ -0,0 +1,39 @@
+using AppMap;
+using Microsoft.EntityFrameworkCore;
+using PetClinic.Data;
+using PetClinic.Models;
+
+namespace PetClinic.Services;
+
+public class OwnerService
+{
+ private readonly PetClinicContext context;
+ private readonly ILogger logger;
+
+ public OwnerService(PetClinicContext context, ILogger logger)
+ {
+ this.context = context;
+ this.logger = logger;
+ }
+
+ public List FindAll()
+ {
+ // LogInformation is hooked by the agent's built-in rules and shows
+ // up in the AppMap labeled "log".
+ logger.LogInformation("Listing all owners");
+ return context.Owners.Include(o => o.Pets).ToList();
+ }
+
+ public Owner? FindByLastName(string lastName) =>
+ context.Owners.Include(o => o.Pets)
+ .FirstOrDefault(o => o.LastName == lastName);
+
+ [Labels("crud")] // appears on this function's classMap entry
+ public Owner Add(Owner owner)
+ {
+ logger.LogInformation("Adding owner {LastName}", owner.LastName);
+ context.Owners.Add(owner);
+ context.SaveChanges();
+ return owner;
+ }
+}
diff --git a/managed/examples/PetClinic/Services/VetService.cs b/managed/examples/PetClinic/Services/VetService.cs
new file mode 100644
index 0000000..ae1daaa
--- /dev/null
+++ b/managed/examples/PetClinic/Services/VetService.cs
@@ -0,0 +1,23 @@
+using Microsoft.EntityFrameworkCore;
+using PetClinic.Data;
+using PetClinic.Models;
+
+namespace PetClinic.Services;
+
+public class VetService
+{
+ private readonly PetClinicContext context;
+
+ public VetService(PetClinicContext context) => this.context = context;
+
+ public List FindAll() => context.Vets.ToList();
+
+ // Async path: the agent records the return when the awaited work
+ // completes, so elapsed covers the real query time and the value is the
+ // unwrapped List, not a Task.
+ public async Task> FindAllAsync()
+ {
+ await Task.Delay(5);
+ return await context.Vets.ToListAsync();
+ }
+}
diff --git a/managed/examples/PetClinic/appmap.yml b/managed/examples/PetClinic/appmap.yml
new file mode 100644
index 0000000..2b8e75f
--- /dev/null
+++ b/managed/examples/PetClinic/appmap.yml
@@ -0,0 +1,4 @@
+name: petclinic
+appmap_dir: tmp/appmap
+packages:
+- path: PetClinic
diff --git a/managed/examples/PetClinic/docs/README.md b/managed/examples/PetClinic/docs/README.md
new file mode 100644
index 0000000..cf0a621
--- /dev/null
+++ b/managed/examples/PetClinic/docs/README.md
@@ -0,0 +1,57 @@
+# PetClinic — ASP.NET Core sample for the AppMap .NET agent
+
+A small [Spring PetClinic](https://github.com/spring-projects/spring-petclinic)-style
+web app (ASP.NET Core MVC controllers + EF Core/SQLite) used to exercise the
+agent's HTTP, application-method, and SQL recording end to end — the .NET
+analog of running appmap-java against the Java PetClinic.
+
+## Layout
+
+| Path | Role |
+|---|---|
+| `Models/` | `Owner`, `Pet`, `Vet` entities |
+| `Data/` | `PetClinicContext` (EF Core) + seed data |
+| `Services/` | `OwnerService`, `VetService` — the instrumented application layer |
+| `Controllers/` | `owners` and `vets` HTTP endpoints |
+| `Program.cs` | `app.UseAppMap()` first in the pipeline |
+| `appmap.yml` | records the `PetClinic` namespace |
+
+## Run it
+
+```sh
+dotnet run --project examples/PetClinic
+# then, against the running server:
+curl localhost:5000/owners
+curl localhost:5000/owners/Davis
+curl localhost:5000/vets
+curl -X POST localhost:5000/owners \
+ -H 'Content-Type: application/json' \
+ -d '{"firstName":"Jean","lastName":"Coleman","city":"Monona"}'
+```
+
+`UseAppMap()` writes one AppMap per request to
+`tmp/appmap/request_recording/` (git-ignored). Each map captures the
+`http_server_request`, the `OwnersController` → `OwnerService` calls, the
+EF-issued `sql_query`, and the response.
+
+## Render diagrams
+
+The maps are standard AppMap 1.2 JSON, so the official
+[`@appland/appmap`](https://www.npmjs.com/package/@appland/appmap) CLI (and
+the AppMap VS Code extension) read them directly:
+
+```sh
+npm install -g @appland/appmap
+appmap sequence-diagram -f png examples/PetClinic/tmp/appmap/request_recording/*__owners.appmap.json
+```
+
+The diagrams in this folder were generated that way:
+
+| Diagram | Request |
+|---|---|
+| `get-owners.sequence.png` | `GET /owners` — controller → service → `SELECT … LEFT JOIN Pets` |
+| `get-owner-by-lastname.sequence.png` | `GET /owners/{lastName}` |
+| `get-vets.sequence.png` | `GET /vets` |
+| `post-owners.sequence.png` | `POST /owners` — `INSERT` via `SaveChanges` |
+
+
diff --git a/managed/examples/PetClinic/docs/get-owner-by-lastname.sequence.png b/managed/examples/PetClinic/docs/get-owner-by-lastname.sequence.png
new file mode 100644
index 0000000..2aca532
Binary files /dev/null and b/managed/examples/PetClinic/docs/get-owner-by-lastname.sequence.png differ
diff --git a/managed/examples/PetClinic/docs/get-owners.sequence.png b/managed/examples/PetClinic/docs/get-owners.sequence.png
new file mode 100644
index 0000000..9c3c02d
Binary files /dev/null and b/managed/examples/PetClinic/docs/get-owners.sequence.png differ
diff --git a/managed/examples/PetClinic/docs/get-owners.sequence.uml b/managed/examples/PetClinic/docs/get-owners.sequence.uml
new file mode 100644
index 0000000..6ba5399
--- /dev/null
+++ b/managed/examples/PetClinic/docs/get-owners.sequence.uml
@@ -0,0 +1,57 @@
+@startuml
+!includeurl https://raw.githubusercontent.com/getappmap/plantuml-theme/main/appmap-theme.puml
+participant HTTP_server_requests as "HTTP server requests"
+participant Data as "Data"
+participant Services as "Services"
+participant Controllers as "Controllers"
+participant Models as "Models"
+participant Database as "Database"
+ [->HTTP_server_requests: GET /owners 155 ms
+ activate HTTP_server_requests
+ HTTP_server_requests->Data: .ctor 0.131 ms
+ activate Data
+ HTTP_server_requests<--Data: void
+ deactivate Data
+ HTTP_server_requests->Services: .ctor 0.000868 ms
+ activate Services
+ HTTP_server_requests<--Services: void
+ deactivate Services
+ HTTP_server_requests->Controllers: .ctor 0.000603 ms
+ activate Controllers
+ HTTP_server_requests<--Controllers: void
+ deactivate Controllers
+ HTTP_server_requests->Controllers: List 146 ms
+ activate Controllers
+ Controllers->Services: FindAll 146 ms
+ activate Services
+ Services->Database: SELECT "o"."Id", "o"."City", "o"."FirstName", "o". 0.134 ms
+ Note right
+SELECT "o"."Id", "o"."City", "o"."FirstName", "o"."LastName", "p"."Id",
+"p"."Name", "p"."OwnerId", "p"."Type" FROM "Owners" AS "o" LEFT JOIN "Pets" AS
+"p" ON "o"."Id" = "p"."OwnerId" ORDER BY "o"."Id"
+ End note
+ Services->Models: .ctor 0.00211 ms
+ activate Models
+ Services<--Models: void
+ deactivate Models
+ Services->Models: .ctor 0.00146 ms
+ activate Models
+ Services<--Models: void
+ deactivate Models
+ Services->Models: .ctor 0.00125 ms
+ activate Models
+ Services<--Models: void
+ deactivate Models
+ Loop 2 times 0.00201 ms
+ Services->Models: .ctor 0.00201 ms
+ activate Models
+ Services<--Models: void
+ deactivate Models
+ End
+ Controllers<--Services: System.Collections.Generic.List`1[[PetClinic.Model
+ deactivate Services
+ HTTP_server_requests<--Controllers: System.Collections.Generic.List`1[[PetClinic.Model
+ deactivate Controllers
+ [<--HTTP_server_requests: 200
+ deactivate HTTP_server_requests
+@enduml
\ No newline at end of file
diff --git a/managed/examples/PetClinic/docs/get-vets-async.sequence.png b/managed/examples/PetClinic/docs/get-vets-async.sequence.png
new file mode 100644
index 0000000..fcb8be1
Binary files /dev/null and b/managed/examples/PetClinic/docs/get-vets-async.sequence.png differ
diff --git a/managed/examples/PetClinic/docs/get-vets.sequence.png b/managed/examples/PetClinic/docs/get-vets.sequence.png
new file mode 100644
index 0000000..7a81aa1
Binary files /dev/null and b/managed/examples/PetClinic/docs/get-vets.sequence.png differ
diff --git a/managed/examples/PetClinic/docs/post-owners.sequence.png b/managed/examples/PetClinic/docs/post-owners.sequence.png
new file mode 100644
index 0000000..24f9512
Binary files /dev/null and b/managed/examples/PetClinic/docs/post-owners.sequence.png differ
diff --git a/managed/harness/README.md b/managed/harness/README.md
new file mode 100644
index 0000000..888b0c5
--- /dev/null
+++ b/managed/harness/README.md
@@ -0,0 +1,149 @@
+# AppMap .NET agent test harness
+
+A deeper-than-fixtures check that the agent works in real, **unmodified**
+codebases: it records a .NET application (or its test suite) under the agent,
+then validates every produced AppMap with the official
+[`appmap`](https://www.npmjs.com/package/@appland/appmap) CLI and asserts
+coverage thresholds. It is the .NET analog of running appmap-java against a
+large reference app.
+
+Two modes:
+
+- **`tests`** — clone a repo and record its test suite (method + label
+ coverage). Default target: Microsoft's
+ [eShopOnWeb](https://github.com/dotnet-architecture/eShopOnWeb) reference
+ app, recorded with zero source changes.
+- **`web`** — launch a web app and record live HTTP traffic (HTTP + SQL
+ coverage). Default target: an in-repo fixture that **references no AppMap
+ package at all** — the agent attaches purely through environment variables.
+
+## Run it
+
+```sh
+npm install -g @appland/appmap --ignore-scripts
+python3 harness/run.py harness/targets/eshoponweb.json # tests mode
+python3 harness/run.py harness/targets/zerotouch-web.json # web mode
+```
+
+`tests` mode builds the agent, clones the target, runs its `prep`/`build`
+steps, runs the `record` command with the agent attached
+(`DOTNET_STARTUP_HOOKS` + `APPMAP_RECORD_PROCESS`), then validates and reports:
+
+```
+=== coverage ===
+maps: 2
+events: 1020
+classMap functions: 125
+packages touched: Microsoft.eShopWeb, System.Security
+labels seen: crypto.digest
+
+harness passed: all maps valid and coverage thresholds met.
+```
+
+`web` mode reports HTTP and SQL event counts instead:
+
+```
+=== coverage ===
+maps: 5
+events: 52
+http_server_request: 5
+sql_query: 4
+classMap functions: 6
+packages touched: ZeroTouchWeb.Widget, ZeroTouchWeb.WidgetContext, ZeroTouchWeb.WidgetService
+```
+
+Flags: `--workdir DIR` (where to clone/record), `--keep` (don't delete it),
+`--hook PATH` (use a prebuilt `AppMap.StartupHook.dll`, skipping the agent
+build), `--determinism` (web targets: record twice and assert the maps are
+structurally identical after dropping volatile fields — ids, timestamps,
+elapsed, headers, object_ids, value text).
+
+## Zero-touch attach (web mode)
+
+The web fixture (`fixtures/ZeroTouchWeb`) is an ordinary ASP.NET Core +
+EF Core/SQLite app. It has **no** `using AppMap`, no package reference, and
+never calls `app.UseAppMap()`. The harness records it by setting only:
+
+```sh
+DOTNET_STARTUP_HOOKS=…/AppMap.StartupHook.dll # method + SQL instrumentation
+ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=AppMap.AspNetCore # prepends UseAppMap() via IStartupFilter
+APPMAP_RECORDING_REQUESTS=true
+```
+
+`AppMap.AspNetCore` carries `[assembly: HostingStartup(...)]`; ASP.NET Core
+loads it (resolved from the agent directory by the startup hook's
+assembly-resolve handler) and runs an `IStartupFilter` that inserts the
+AppMap middleware at the front of the pipeline. This is the .NET equivalent
+of a Java `-javaagent` auto-registering its servlet filter — drop the agent
+next to any ASP.NET Core app and get per-request HTTP→method→SQL maps with no
+code change.
+
+## What it validates
+
+For every AppMap produced:
+
+- **Structural**: the `version` / `metadata` / `classMap` / `events` keys
+ are present and every `return` event has a matching `call` (no dangling
+ frames).
+- **Tooling**: the official `appmap sequence-diagram` accepts the map
+ (this is what caught the `class_map` vs `classMap` bug originally).
+
+Then, across all maps, it asserts the manifest's coverage thresholds:
+total events, distinct `classMap` functions, and that each expected label
+was produced (e.g. `crypto.digest` from eShop's password hashing).
+
+## Adding a target
+
+A target manifest is JSON:
+
+```json
+{
+ "name": "eShopOnWeb",
+ "repo": "https://github.com/dotnet-architecture/eShopOnWeb.git",
+ "ref": "main",
+ "prep": ["echo '{\"version\":\"1.0\",\"libraries\":[]}' > src/Web/libman.json"],
+ "appmap_packages": ["Microsoft.eShopWeb"],
+ "build": "dotnet build tests/UnitTests/UnitTests.csproj -c Release && ...",
+ "record": "dotnet test tests/UnitTests/UnitTests.csproj -c Release --no-build ; ...",
+ "thresholds": {
+ "min_maps": 1,
+ "min_events": 150,
+ "min_classmap_functions": 40,
+ "expected_labels": ["crypto.digest"]
+ }
+}
+```
+
+Common fields:
+
+- `name` — used for the generated `appmap.yml`.
+- `local_path` (in-repo target) or `repo`/`ref` (cloned target).
+- `prep` runs before `build` (non-fatal). eShopOnWeb needs its `libman.json`
+ neutralized because the client-side JS restore reaches cdnjs, which
+ CI/sandbox networks often block — server code is unaffected.
+- `appmap_packages` become the `packages:` of a generated `appmap.yml`.
+- `thresholds` — any of `min_maps`, `min_events`, `min_http_events`,
+ `min_sql_events`, `min_classmap_functions`, `expected_labels`.
+
+`tests` mode adds `record` (run with the agent attached; a non-zero exit
+from a failing app test does not fail the harness — only invalid maps or
+unmet thresholds do).
+
+`web` mode adds `launch` (the built DLL to run), `ready_path` (polled until
+the server answers), and `requests` (the HTTP calls to drive, each
+`{method, path, json?}`). See `targets/zerotouch-web.json` (SQLite) and
+`targets/sqlserver-web.json` (SQL Server — the `sql-server-web` CI job runs
+it against an mssql service container, since that provider, not SQLite, is
+what exposed the SqlHooks partial-load bug).
+
+## Known scope
+
+- `tests` mode records via process recording, so it captures instrumented
+ methods and label coverage but not HTTP/SQL (eShopOnWeb's tests use the
+ in-memory provider). `web` mode covers HTTP + SQL. A natural extension is
+ a `web` target pointed at a large real app (eShop with Postgres), which
+ needs container infrastructure in CI.
+- Findings-level (RCA-style) assertions — planting an N+1 / unauthenticated
+ endpoint / logged secret and asserting AppMap's analysis flags it — build
+ on the events and labels this harness already exercises and are the next
+ step up in depth.
diff --git a/managed/harness/fixtures/SqlServerWeb/Data.cs b/managed/harness/fixtures/SqlServerWeb/Data.cs
new file mode 100644
index 0000000..8a6fce9
--- /dev/null
+++ b/managed/harness/fixtures/SqlServerWeb/Data.cs
@@ -0,0 +1,50 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace SqlServerWeb;
+
+public class Widget
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public int Quantity { get; set; }
+}
+
+public class ShopContext : DbContext
+{
+ public ShopContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Widgets => Set();
+}
+
+public class WidgetService
+{
+ private readonly ShopContext db;
+
+ public WidgetService(ShopContext db) => this.db = db;
+
+ public List All() => db.Widgets.OrderBy(w => w.Id).ToList();
+
+ public Widget? Find(int id) => db.Widgets.FirstOrDefault(w => w.Id == id);
+
+ public Widget Add(Widget widget)
+ {
+ db.Widgets.Add(widget);
+ db.SaveChanges();
+ return widget;
+ }
+}
+
+public static class SeedData
+{
+ public static void Initialize(ShopContext db)
+ {
+ db.Database.EnsureCreated();
+ if (db.Widgets.Any())
+ return;
+ db.Widgets.AddRange(
+ new Widget { Name = "Sprocket", Quantity = 12 },
+ new Widget { Name = "Cog", Quantity = 7 },
+ new Widget { Name = "Flange", Quantity = 3 });
+ db.SaveChanges();
+ }
+}
diff --git a/managed/harness/fixtures/SqlServerWeb/Program.cs b/managed/harness/fixtures/SqlServerWeb/Program.cs
new file mode 100644
index 0000000..34dfd19
--- /dev/null
+++ b/managed/harness/fixtures/SqlServerWeb/Program.cs
@@ -0,0 +1,49 @@
+using Microsoft.Data.SqlClient;
+using Microsoft.EntityFrameworkCore;
+using SqlServerWeb;
+
+// No AppMap reference: the agent attaches via env vars only (zero-touch).
+var builder = WebApplication.CreateBuilder(args);
+
+// Connection string from ConnectionStrings__Default (CI sets it to the SQL
+// Server service container); a localhost default keeps it runnable by hand.
+var connectionString = builder.Configuration.GetConnectionString("Default")
+ ?? "Server=localhost,1433;Database=AppMapHarness;User Id=sa;"
+ + "Password=Your_password123;TrustServerCertificate=True;Encrypt=False";
+
+builder.Services.AddDbContext(o =>
+ o.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure()));
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+// Log the target (no password) so a CI failure shows whether the connection
+// string was read from the environment or fell back to the default.
+var target = new SqlConnectionStringBuilder(connectionString);
+app.Logger.LogInformation("DB target server={Server} database={Db} user={User}",
+ target.DataSource, target.InitialCatalog, target.UserID);
+
+// Seed with retries: a SQL Server container can still be coming up. Never
+// crash the host on failure — /health must answer so the harness can drive it.
+using (var scope = app.Services.CreateScope())
+{
+ var db = scope.ServiceProvider.GetRequiredService();
+ for (var attempt = 1; attempt <= 20; attempt++)
+ {
+ try { SeedData.Initialize(db); break; }
+ catch (Exception e)
+ {
+ app.Logger.LogWarning("seed attempt {Attempt} failed: {Type}: {Message}",
+ attempt, e.GetType().Name, e.InnerException?.Message ?? e.Message);
+ Thread.Sleep(3000);
+ }
+ }
+}
+
+app.MapGet("/health", () => "ok");
+app.MapGet("/widgets", (WidgetService svc) => svc.All());
+app.MapGet("/widgets/{id:int}", (int id, WidgetService svc) =>
+ svc.Find(id) is { } w ? Results.Ok(w) : Results.NotFound());
+app.MapPost("/widgets", (Widget widget, WidgetService svc) => Results.Ok(svc.Add(widget)));
+
+app.Run();
diff --git a/managed/harness/fixtures/SqlServerWeb/SqlServerWeb.csproj b/managed/harness/fixtures/SqlServerWeb/SqlServerWeb.csproj
new file mode 100644
index 0000000..dba43ed
--- /dev/null
+++ b/managed/harness/fixtures/SqlServerWeb/SqlServerWeb.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+ net8.0
+ enable
+ enable
+ SqlServerWeb
+
+
+
+
+
+
+
+
diff --git a/managed/harness/fixtures/SystemWebApp/Default.aspx b/managed/harness/fixtures/SystemWebApp/Default.aspx
new file mode 100644
index 0000000..db1351e
--- /dev/null
+++ b/managed/harness/fixtures/SystemWebApp/Default.aspx
@@ -0,0 +1,2 @@
+<%@ Page Language="C#" %>
+<% Response.Write("ok"); %>
diff --git a/managed/harness/fixtures/SystemWebApp/appmap.yml b/managed/harness/fixtures/SystemWebApp/appmap.yml
new file mode 100644
index 0000000..2adaf54
--- /dev/null
+++ b/managed/harness/fixtures/SystemWebApp/appmap.yml
@@ -0,0 +1,4 @@
+name: SystemWebApp
+appmap_dir: tmp/appmap
+packages:
+- path: SystemWebApp
diff --git a/managed/harness/fixtures/SystemWebApp/web.config b/managed/harness/fixtures/SystemWebApp/web.config
new file mode 100644
index 0000000..e3f0a14
--- /dev/null
+++ b/managed/harness/fixtures/SystemWebApp/web.config
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/managed/harness/fixtures/ZeroTouchWeb/Program.cs b/managed/harness/fixtures/ZeroTouchWeb/Program.cs
new file mode 100644
index 0000000..069776f
--- /dev/null
+++ b/managed/harness/fixtures/ZeroTouchWeb/Program.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore;
+using ZeroTouchWeb;
+
+// Note: no `using AppMap...` and no `app.UseAppMap()`. The agent attaches via
+// environment variables only (see harness/README.md).
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddDbContext(o => o.UseSqlite("Data Source=zerotouch.db"));
+builder.Services.AddScoped();
+
+var app = builder.Build();
+
+using (var scope = app.Services.CreateScope())
+{
+ SeedData.Initialize(scope.ServiceProvider.GetRequiredService());
+}
+
+app.MapGet("/health", () => "ok");
+app.MapGet("/widgets", (WidgetService svc) => svc.All());
+app.MapGet("/widgets/{id:int}", (int id, WidgetService svc) =>
+ svc.Find(id) is { } w ? Results.Ok(w) : Results.NotFound());
+app.MapPost("/widgets", (Widget widget, WidgetService svc) => Results.Ok(svc.Add(widget)));
+
+app.Run();
diff --git a/managed/harness/fixtures/ZeroTouchWeb/Widgets.cs b/managed/harness/fixtures/ZeroTouchWeb/Widgets.cs
new file mode 100644
index 0000000..17da8f8
--- /dev/null
+++ b/managed/harness/fixtures/ZeroTouchWeb/Widgets.cs
@@ -0,0 +1,53 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace ZeroTouchWeb;
+
+public class Widget
+{
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public int Quantity { get; set; }
+}
+
+public class WidgetContext : DbContext
+{
+ public WidgetContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Widgets => Set();
+}
+
+///
+/// A small service layer so recordings show method calls wrapping the SQL —
+/// the shape the agent is meant to capture end to end (HTTP → method → SQL).
+///
+public class WidgetService
+{
+ private readonly WidgetContext db;
+
+ public WidgetService(WidgetContext db) => this.db = db;
+
+ public List All() => db.Widgets.OrderBy(w => w.Id).ToList();
+
+ public Widget? Find(int id) => db.Widgets.FirstOrDefault(w => w.Id == id);
+
+ public Widget Add(Widget widget)
+ {
+ db.Widgets.Add(widget);
+ db.SaveChanges();
+ return widget;
+ }
+}
+
+public static class SeedData
+{
+ public static void Initialize(WidgetContext db)
+ {
+ db.Database.EnsureDeleted();
+ db.Database.EnsureCreated();
+ db.Widgets.AddRange(
+ new Widget { Name = "Sprocket", Quantity = 12 },
+ new Widget { Name = "Cog", Quantity = 7 },
+ new Widget { Name = "Flange", Quantity = 3 });
+ db.SaveChanges();
+ }
+}
diff --git a/managed/harness/fixtures/ZeroTouchWeb/ZeroTouchWeb.csproj b/managed/harness/fixtures/ZeroTouchWeb/ZeroTouchWeb.csproj
new file mode 100644
index 0000000..ccd86f3
--- /dev/null
+++ b/managed/harness/fixtures/ZeroTouchWeb/ZeroTouchWeb.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+ net8.0
+ enable
+ enable
+ ZeroTouchWeb
+ true
+
+
+
+
+
+
+
diff --git a/managed/harness/run.py b/managed/harness/run.py
new file mode 100644
index 0000000..db27d4c
--- /dev/null
+++ b/managed/harness/run.py
@@ -0,0 +1,469 @@
+#!/usr/bin/env python3
+"""
+App-agnostic AppMap .NET agent test harness.
+
+Records a real .NET application under the agent, then validates every produced
+AppMap with the official `appmap` CLI and asserts coverage thresholds. The
+point is to prove the agent works in real codebases, not just on fixtures.
+
+Two manifest modes:
+
+ - "tests" (default): clone a repo, run its test suite with the agent
+ attached (startup hook + process recording). Method + label coverage.
+ Example: harness/targets/eshoponweb.json
+
+ - "web": build a web app, launch it with the agent attached purely through
+ environment variables (DOTNET_STARTUP_HOOKS + the zero-touch
+ ASPNETCORE_HOSTINGSTARTUPASSEMBLIES HostingStartup), drive HTTP requests,
+ and assert HTTP + SQL coverage in the per-request AppMaps.
+ Example: harness/targets/zerotouch-web.json
+
+Usage:
+ python3 harness/run.py harness/targets/eshoponweb.json
+ python3 harness/run.py harness/targets/zerotouch-web.json --keep
+
+See harness/README.md for the manifest schema.
+"""
+import argparse
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+
+def log(msg):
+ print(f"[harness] {msg}", flush=True)
+
+
+def run(cmd, cwd, env=None, check=True):
+ log(f"$ {cmd} (cwd={cwd})")
+ result = subprocess.run(cmd, cwd=cwd, env=env, shell=True)
+ if check and result.returncode != 0:
+ raise SystemExit(f"command failed ({result.returncode}): {cmd}")
+ return result.returncode
+
+
+def build_agent():
+ """Build the agent's startup hook once and return its DLL path. The build
+ output also contains AppMap.AspNetCore.dll, which the hook's
+ assembly-resolve handler loads for the zero-touch HostingStartup."""
+ log("building the AppMap agent (startup hook + AspNetCore)")
+ run("dotnet build src/AppMap.StartupHook/AppMap.StartupHook.csproj -c Release",
+ cwd=REPO_ROOT)
+ hook = REPO_ROOT / "src/AppMap.StartupHook/bin/Release/net8.0/AppMap.StartupHook.dll"
+ if not hook.exists():
+ raise SystemExit(f"startup hook not found at {hook}")
+ return hook
+
+
+def resolve_app_dir(manifest, workdir):
+ """A target is either a repo to clone or an in-repo local_path."""
+ if manifest.get("local_path"):
+ app_dir = (REPO_ROOT / manifest["local_path"]).resolve()
+ if not app_dir.exists():
+ raise SystemExit(f"local_path not found: {app_dir}")
+ return app_dir
+ app_dir = workdir / manifest["name"]
+ if app_dir.exists():
+ shutil.rmtree(app_dir)
+ ref = manifest.get("ref", "main")
+ run(f"git clone --depth 1 --branch {ref} {manifest['repo']} {app_dir}", cwd=workdir)
+ return app_dir
+
+
+def write_config(workdir, manifest):
+ """A generated appmap.yml scoped to the manifest's packages. Kept in the
+ workdir so an in-repo fixture stays untouched."""
+ packages = "".join(f"- path: {p}\n" for p in manifest["appmap_packages"])
+ config = workdir / "appmap.yml"
+ config.write_text(
+ f"name: {manifest['name']}\nappmap_dir: tmp/appmap\npackages:\n{packages}")
+ return config
+
+
+def agent_env(hook, config, out_dir, extra=None):
+ env = dict(os.environ)
+ env.update({
+ "DOTNET_CLI_TELEMETRY_OPTOUT": "1",
+ "DOTNET_NOLOGO": "1",
+ "APPMAP_CONFIG_FILE": str(config),
+ "APPMAP_OUTPUT_DIRECTORY": str(out_dir),
+ "DOTNET_STARTUP_HOOKS": str(hook),
+ })
+ if extra:
+ env.update(extra)
+ return env
+
+
+# --- tests mode ------------------------------------------------------------
+
+def record_tests(app_dir, manifest, hook, config, out_dir):
+ env = agent_env(hook, config, out_dir, {"APPMAP_RECORD_PROCESS": "true"})
+ # The record command (often `dotnet test`) may exit non-zero if some app
+ # tests fail; that does not invalidate the maps, so don't treat it as fatal.
+ run(manifest["record"], cwd=app_dir, env=env, check=False)
+ return sorted(out_dir.rglob("*.appmap.json"))
+
+
+# --- web mode --------------------------------------------------------------
+
+def free_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def http_call(base_url, spec, timeout=15):
+ url = base_url + spec["path"]
+ data = None
+ headers = {}
+ if "json" in spec:
+ data = json.dumps(spec["json"]).encode()
+ headers["Content-Type"] = "application/json"
+ req = urllib.request.Request(url, data=data, method=spec.get("method", "GET"),
+ headers=headers)
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.status
+ except urllib.error.HTTPError as e:
+ return e.code # 404 etc. are still recorded maps
+
+
+def wait_ready(base_url, path, timeout=60):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ with urllib.request.urlopen(base_url + path, timeout=2) as resp:
+ if resp.status < 500:
+ return True
+ except Exception:
+ time.sleep(0.5)
+ return False
+
+
+def record_web(app_dir, manifest, hook, config, out_dir, workdir):
+ port = free_port()
+ base_url = f"http://127.0.0.1:{port}"
+ launch = (app_dir / manifest["launch"]).resolve()
+ env = agent_env(hook, config, out_dir, {
+ "APPMAP_RECORDING_REQUESTS": "true",
+ "APPMAP_RECORDING_REMOTE": "false",
+ # The zero-touch attach: the app references no AppMap package.
+ "ASPNETCORE_HOSTINGSTARTUPASSEMBLIES": "AppMap.AspNetCore",
+ "ASPNETCORE_URLS": base_url,
+ })
+ log_path = workdir / "server.log"
+ # Launch from the app's own directory so the agent resolves the git root
+ # (and relativizes source paths) the way a normally-run app would. Clean
+ # any stale SQLite files first (a leftover -wal can fault the next run).
+ for db in app_dir.glob("*.db*"):
+ db.unlink()
+ log(f"launching {launch.name} on {base_url} (zero-touch attach)")
+ ready = False
+ with open(log_path, "w") as logfile:
+ proc = subprocess.Popen(f"dotnet {launch}", cwd=app_dir, env=env,
+ shell=True, stdout=logfile, stderr=subprocess.STDOUT)
+ try:
+ ready = wait_ready(base_url, manifest.get("ready_path", "/"), timeout=120)
+ if ready:
+ for spec in manifest["requests"]:
+ status = http_call(base_url, spec)
+ log(f" {spec.get('method', 'GET')} {spec['path']} -> {status}")
+ time.sleep(1) # let the last request's map flush
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=15)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+ maps = sorted(out_dir.rglob("*.appmap.json"))
+ # Always surface the app's own log: a request can 500 (e.g. the backing
+ # service is unreachable) while the app is still "ready" and producing
+ # maps, so the error only lives here. Diagnosable without a local repro.
+ tail = log_path.read_text(errors="replace").splitlines()[-40:]
+ log(f"--- {log_path.name} (tail) ---\n" + "\n".join(tail))
+ if not ready:
+ raise SystemExit("web app never became ready")
+ return maps
+
+
+# --- validation + coverage -------------------------------------------------
+
+def structural_check(doc):
+ problems = []
+ for key in ("version", "metadata", "classMap", "events"):
+ if key not in doc:
+ problems.append(f"missing top-level '{key}'")
+ events = doc.get("events", [])
+ call_ids = {e["id"] for e in events if e.get("event") == "call"}
+ for e in events:
+ if e.get("event") == "return" and e.get("parent_id") not in call_ids:
+ problems.append(f"return event {e['id']} has no matching call")
+ break
+
+ # Paths must be repo-relative with forward slashes, or a map recorded on
+ # one machine/OS won't resolve on another (the R4 cross-platform
+ # requirement). Guards against the SourceLocator regressing to absolute.
+ def is_bad(p):
+ return p.startswith("/") or "\\" in p or (len(p) > 1 and p[1] == ":")
+ paths = [n["location"] for n in iter_classmap(doc) if n.get("location")]
+ paths += [e["path"] for e in events if e.get("path")]
+ bad = next((p for p in paths if is_bad(p)), None)
+ if bad:
+ problems.append(f"non-relative source path: {bad}")
+ return problems
+
+
+def iter_classmap(doc):
+ def walk(node):
+ yield node
+ for child in node.get("children", []):
+ yield from walk(child)
+ for root in doc.get("classMap", []):
+ yield from walk(root)
+
+
+def cli_accepts(map_path):
+ """The official CLI must be able to build a sequence diagram from it."""
+ result = subprocess.run(
+ f"appmap sequence-diagram -f json '{map_path}'",
+ cwd=map_path.parent, shell=True,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ return result.returncode == 0, result.stdout.decode(errors="replace")
+
+
+def collect_functions(node, chain, out):
+ chain = chain + [node["name"]]
+ if node.get("type") == "function":
+ out.append((".".join(chain), node.get("labels") or []))
+ for child in node.get("children", []):
+ collect_functions(child, chain, out)
+
+
+def analyze(maps):
+ stats = {
+ "maps": len(maps), "events": 0, "http_events": 0, "sql_events": 0,
+ "functions": set(), "labels": set(), "packages": set(), "failures": [],
+ }
+ for m in maps:
+ doc = json.loads(m.read_text())
+ problems = structural_check(doc)
+ ok, output = cli_accepts(m)
+ if not ok:
+ last = output.strip().splitlines()[-1] if output.strip() else ""
+ problems.append(f"appmap CLI rejected the map: {last}")
+ if problems:
+ stats["failures"].append((m.name, problems))
+
+ for e in doc.get("events", []):
+ stats["events"] += 1
+ if e.get("http_server_request"):
+ stats["http_events"] += 1
+ if e.get("sql_query"):
+ stats["sql_events"] += 1
+ for root in doc.get("classMap", []):
+ fns = []
+ collect_functions(root, [], fns)
+ for fqn, fn_labels in fns:
+ stats["functions"].add(fqn)
+ stats["labels"].update(fn_labels)
+ parts = fqn.split(".")
+ if len(parts) >= 2:
+ stats["packages"].add(".".join(parts[:2]))
+
+ stats["functions"] = sorted(stats["functions"])
+ stats["labels"] = sorted(stats["labels"])
+ stats["packages"] = sorted(stats["packages"])
+ return stats
+
+
+def assert_thresholds(stats, manifest):
+ t = manifest.get("thresholds", {})
+ errors = [f"{name}: {probs}" for name, probs in stats["failures"]]
+
+ checks = {
+ "min_maps": "maps", "min_events": "events",
+ "min_http_events": "http_events", "min_sql_events": "sql_events",
+ "min_classmap_functions": ("functions", len),
+ }
+ for key, metric in checks.items():
+ minimum = t.get(key)
+ if minimum is None:
+ continue
+ value = len(stats[metric[0]]) if isinstance(metric, tuple) else stats[metric]
+ name = metric[0] if isinstance(metric, tuple) else metric
+ if value < minimum:
+ errors.append(f"{name} {value} < required {minimum}")
+
+ for label in t.get("expected_labels", []):
+ if label not in stats["labels"]:
+ errors.append(f"expected label '{label}' not found")
+ return errors
+
+
+def report(stats):
+ print("\n=== coverage ===")
+ print(f"maps: {stats['maps']}")
+ print(f"events: {stats['events']}")
+ print(f"http_server_request: {stats['http_events']}")
+ print(f"sql_query: {stats['sql_events']}")
+ print(f"classMap functions: {len(stats['functions'])}")
+ print(f"packages touched: {', '.join(stats['packages'])}")
+ print(f"labels seen: {', '.join(stats['labels']) or '(none)'}")
+
+
+# --- determinism (R5) ------------------------------------------------------
+
+def _norm_value(v):
+ # Keep the shape (name + type); drop the runtime value and object_id,
+ # which legitimately vary between runs.
+ return {"name": v.get("name"), "class": v.get("class")}
+
+
+def normalize_structure(doc):
+ """A map reduced to its structure: event/method/SQL/HTTP shape and the
+ classMap, with the volatile fields the spec allows to differ removed —
+ ids, parent_ids, elapsed, timestamps, headers, object_ids, value text."""
+ events = []
+ for e in doc.get("events", []):
+ n = {k: e[k] for k in ("event", "defined_class", "method_id", "static")
+ if k in e}
+ if e.get("http_server_request"):
+ h = e["http_server_request"]
+ n["http"] = {"method": h.get("request_method"),
+ "path": h.get("path_info"),
+ "route": h.get("normalized_path_info")}
+ if e.get("http_server_response"):
+ n["status"] = e["http_server_response"].get("status")
+ if e.get("sql_query"):
+ q = e["sql_query"]
+ n["sql"] = {"sql": q.get("sql"), "db": q.get("database_type")}
+ for vk in ("parameters", "message", "return_value", "receiver"):
+ val = e.get(vk)
+ if isinstance(val, list):
+ n[vk] = [_norm_value(x) for x in val]
+ elif isinstance(val, dict):
+ n[vk] = _norm_value(val)
+ if e.get("exceptions"):
+ n["exceptions"] = [{"class": x.get("class")} for x in e["exceptions"]]
+ events.append(n)
+
+ def node(c):
+ out = {"name": c.get("name"), "type": c.get("type")}
+ if c.get("labels"):
+ out["labels"] = sorted(c["labels"])
+ if c.get("location"):
+ out["location"] = c["location"]
+ if c.get("children"):
+ out["children"] = [node(ch) for ch in c["children"]]
+ return out
+
+ return {"events": events, "classMap": [node(r) for r in doc.get("classMap", [])]}
+
+
+def request_key(doc):
+ for e in doc.get("events", []):
+ h = e.get("http_server_request")
+ if h:
+ return (h.get("request_method"), h.get("path_info"))
+ return ("process", (doc.get("metadata", {}).get("recorder") or {}).get("type"))
+
+
+def compare_determinism(maps_a, maps_b):
+ def keyed(maps):
+ out = {}
+ for m in maps:
+ doc = json.loads(m.read_text())
+ out[request_key(doc)] = normalize_structure(doc)
+ return out
+
+ a, b = keyed(maps_a), keyed(maps_b)
+ errors = []
+ if set(a) != set(b):
+ errors.append(f"the two runs recorded different requests: "
+ f"{sorted(set(a) ^ set(b))}")
+ for k in sorted(set(a) & set(b)):
+ if a[k] != b[k]:
+ errors.append(f"structure differs between runs for {k}")
+ return errors
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("manifest", help="path to a target manifest JSON")
+ parser.add_argument("--workdir", help="where to clone/record (default: temp)")
+ parser.add_argument("--keep", action="store_true", help="keep the workdir")
+ parser.add_argument("--hook", help="prebuilt AppMap.StartupHook.dll (skips agent build)")
+ parser.add_argument("--determinism", action="store_true",
+ help="record a web target twice and assert identical structure")
+ args = parser.parse_args()
+
+ manifest = json.loads(Path(args.manifest).read_text())
+ mode = manifest.get("mode", "tests")
+ workdir = Path(args.workdir) if args.workdir else Path(tempfile.mkdtemp(prefix="appmap-harness-"))
+ workdir.mkdir(parents=True, exist_ok=True)
+ out_dir = workdir / "appmap"
+ if out_dir.exists():
+ shutil.rmtree(out_dir)
+ log(f"target={manifest['name']} mode={mode} workdir={workdir}")
+
+ try:
+ hook = Path(args.hook) if args.hook else build_agent()
+ app_dir = resolve_app_dir(manifest, workdir)
+ for cmd in manifest.get("prep", []):
+ run(cmd, cwd=app_dir, check=False)
+ run(manifest["build"], cwd=app_dir)
+ config = write_config(workdir, manifest)
+
+ if mode == "web":
+ maps = record_web(app_dir, manifest, hook, config, out_dir, workdir)
+ else:
+ maps = record_tests(app_dir, manifest, hook, config, out_dir)
+ log(f"produced {len(maps)} AppMap(s)")
+ if not maps:
+ raise SystemExit("no AppMaps were produced")
+
+ stats = analyze(maps)
+ report(stats)
+ errors = assert_thresholds(stats, manifest)
+ if errors:
+ print("\n=== FAILURES ===")
+ for e in errors:
+ print(f" - {e}")
+ raise SystemExit(f"\nharness failed: {len(errors)} problem(s)")
+ print("\nharness passed: all maps valid and coverage thresholds met.")
+
+ if args.determinism:
+ if mode != "web":
+ raise SystemExit("--determinism is supported for web targets only")
+ out_dir2 = workdir / "appmap2"
+ if out_dir2.exists():
+ shutil.rmtree(out_dir2)
+ log("recording a second time to check determinism")
+ maps2 = record_web(app_dir, manifest, hook, config, out_dir2, workdir)
+ diffs = compare_determinism(maps, maps2)
+ if diffs:
+ print("\n=== DETERMINISM FAILURES ===")
+ for d in diffs:
+ print(f" - {d}")
+ raise SystemExit(f"\nharness failed: {len(diffs)} determinism diff(s)")
+ print(f"determinism: all {len(maps)} maps reproduced identically "
+ "(modulo ids, timestamps, durations, and values).")
+ finally:
+ if not args.keep:
+ shutil.rmtree(workdir, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/managed/harness/targets/eshoponweb.json b/managed/harness/targets/eshoponweb.json
new file mode 100644
index 0000000..44e0cdd
--- /dev/null
+++ b/managed/harness/targets/eshoponweb.json
@@ -0,0 +1,17 @@
+{
+ "name": "eShopOnWeb",
+ "repo": "https://github.com/dotnet-architecture/eShopOnWeb.git",
+ "ref": "main",
+ "prep": [
+ "echo '{\"version\":\"1.0\",\"libraries\":[]}' > src/Web/libman.json"
+ ],
+ "appmap_packages": ["Microsoft.eShopWeb"],
+ "build": "dotnet build tests/UnitTests/UnitTests.csproj -c Release && dotnet build tests/IntegrationTests/IntegrationTests.csproj -c Release",
+ "record": "dotnet test tests/UnitTests/UnitTests.csproj -c Release --no-build ; dotnet test tests/IntegrationTests/IntegrationTests.csproj -c Release --no-build",
+ "thresholds": {
+ "min_maps": 1,
+ "min_events": 150,
+ "min_classmap_functions": 40,
+ "expected_labels": ["crypto.digest"]
+ }
+}
diff --git a/managed/harness/targets/sqlserver-web.json b/managed/harness/targets/sqlserver-web.json
new file mode 100644
index 0000000..d5b9cf4
--- /dev/null
+++ b/managed/harness/targets/sqlserver-web.json
@@ -0,0 +1,20 @@
+{
+ "name": "SqlServerWeb",
+ "mode": "web",
+ "local_path": "harness/fixtures/SqlServerWeb",
+ "appmap_packages": ["SqlServerWeb"],
+ "build": "dotnet build -c Release",
+ "launch": "bin/Release/net8.0/SqlServerWeb.dll",
+ "ready_path": "/health",
+ "requests": [
+ { "method": "GET", "path": "/widgets" },
+ { "method": "GET", "path": "/widgets/2" },
+ { "method": "POST", "path": "/widgets", "json": { "name": "Gizmo", "quantity": 5 } }
+ ],
+ "thresholds": {
+ "min_maps": 3,
+ "min_http_events": 3,
+ "min_sql_events": 3,
+ "min_classmap_functions": 3
+ }
+}
diff --git a/managed/harness/targets/zerotouch-web.json b/managed/harness/targets/zerotouch-web.json
new file mode 100644
index 0000000..7ef7a84
--- /dev/null
+++ b/managed/harness/targets/zerotouch-web.json
@@ -0,0 +1,21 @@
+{
+ "name": "ZeroTouchWeb",
+ "mode": "web",
+ "local_path": "harness/fixtures/ZeroTouchWeb",
+ "appmap_packages": ["ZeroTouchWeb"],
+ "build": "dotnet build -c Release",
+ "launch": "bin/Release/net8.0/ZeroTouchWeb.dll",
+ "ready_path": "/health",
+ "requests": [
+ { "method": "GET", "path": "/widgets" },
+ { "method": "GET", "path": "/widgets/2" },
+ { "method": "GET", "path": "/widgets/999" },
+ { "method": "POST", "path": "/widgets", "json": { "name": "Gizmo", "quantity": 5 } }
+ ],
+ "thresholds": {
+ "min_maps": 4,
+ "min_http_events": 4,
+ "min_sql_events": 3,
+ "min_classmap_functions": 3
+ }
+}
diff --git a/managed/src/AppMap.Agent/AgentBootstrap.cs b/managed/src/AppMap.Agent/AgentBootstrap.cs
new file mode 100644
index 0000000..f86eee2
--- /dev/null
+++ b/managed/src/AppMap.Agent/AgentBootstrap.cs
@@ -0,0 +1,68 @@
+using AppMap.Config;
+using AppMap.Instrumentation;
+using AppMap.Output;
+using AppMap.Record;
+using AppMap.Util;
+
+namespace AppMap;
+
+///
+/// Agent entry point — the analog of appmap-java's premain. Invoked from the
+/// startup hook (DOTNET_STARTUP_HOOKS), from the ASP.NET Core middleware, or
+/// explicitly by the host application. Idempotent.
+///
+public static class AgentBootstrap
+{
+ private static int initialized;
+
+ public static void Init()
+ {
+ if (Interlocked.Exchange(ref initialized, 1) == 1)
+ return;
+
+ try
+ {
+ var config = AppMapConfig.Current;
+ Logger.Debug($"AppMap .NET agent starting (app: {config.Name})");
+
+ new Instrumentor(config).Start();
+ SqlHooks.Install();
+ BuiltinHooks.Install();
+
+ if (Properties.RecordingProcess)
+ StartProcessRecording();
+ }
+ catch (Exception e)
+ {
+ // The agent must never prevent the host application from starting.
+ Logger.Error("agent initialization failed", e);
+ }
+ }
+
+ private static void StartProcessRecording()
+ {
+ Recorder.Instance.Start(new Metadata
+ {
+ RecorderName = "process_recording",
+ RecorderType = "process",
+ Name = $"Process recording {DateTime.Now:yyyy-MM-ddTHH:mm:ss}",
+ });
+ AppDomain.CurrentDomain.ProcessExit += (_, _) =>
+ {
+ try
+ {
+ // dotnet's CLI host inherits the startup hook too; don't
+ // litter the output directory with its empty recording.
+ var recording = Recorder.Instance.Stop();
+ if (recording is { EventCount: > 0 })
+ recording.Save();
+ else
+ recording?.Discard();
+ }
+ catch (Exception e)
+ {
+ Logger.Error("failed to write process recording", e);
+ }
+ };
+ }
+}
diff --git a/managed/src/AppMap.Agent/AppMap.Agent.csproj b/managed/src/AppMap.Agent/AppMap.Agent.csproj
new file mode 100644
index 0000000..e1511fd
--- /dev/null
+++ b/managed/src/AppMap.Agent/AppMap.Agent.csproj
@@ -0,0 +1,36 @@
+
+
+
+
+ net8.0;netstandard2.0
+ latest
+ enable
+ enable
+ AppMap
+ AppMap agent for .NET: records code execution to AppMap JSON files.
+ AppMap.Agent
+ AppMap, Inc.
+ MIT
+ https://github.com/getappmap/appmap-dotnet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/managed/src/AppMap.Agent/Config/AppMapConfig.cs b/managed/src/AppMap.Agent/Config/AppMapConfig.cs
new file mode 100644
index 0000000..cdf52db
--- /dev/null
+++ b/managed/src/AppMap.Agent/Config/AppMapConfig.cs
@@ -0,0 +1,160 @@
+using AppMap.Util;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace AppMap.Config;
+
+///
+/// In-memory representation of appmap.yml, mirroring
+/// com.appland.appmap.config.AppMapConfig. The file is searched for in the
+/// current directory and its ancestors unless APPMAP_CONFIG_FILE is set.
+///
+public sealed class AppMapConfig
+{
+ [YamlMember(Alias = "name")]
+ public string? Name { get; set; }
+
+ [YamlMember(Alias = "appmap_dir")]
+ public string AppMapDir { get; set; } = "tmp/appmap";
+
+ [YamlMember(Alias = "packages")]
+ public List Packages { get; set; } = new();
+
+ /// Directory containing appmap.yml; relative paths resolve against it.
+ [YamlIgnore]
+ public string BaseDirectory { get; set; } = Directory.GetCurrentDirectory();
+
+ [YamlIgnore]
+ public string OutputDirectory =>
+ Properties.OutputDirectory ?? System.IO.Path.Combine(BaseDirectory, AppMapDir);
+
+ private static AppMapConfig? current;
+
+ public static AppMapConfig Current => current ??= Load();
+
+ ///
+ /// Finds the package entry covering a fully qualified name
+ /// (namespace.Type.Member), or null if the name is not instrumented.
+ /// First matching package wins, as in the Java agent.
+ ///
+ public AppMapPackage? FindPackage(string fullyQualifiedName)
+ {
+ foreach (var pkg in Packages)
+ {
+ if (pkg.Matches(fullyQualifiedName))
+ return pkg;
+ }
+ return null;
+ }
+
+ public static AppMapConfig Load()
+ {
+ var path = Properties.ConfigFile ?? FindConfigFile();
+ if (path == null || !File.Exists(path))
+ {
+ Logger.Warn("appmap.yml not found; only HTTP and SQL events will be recorded. "
+ + "Create appmap.yml with a 'packages:' list to record application code.");
+ return new AppMapConfig { Name = AppNameFallback() };
+ }
+
+ try
+ {
+ var deserializer = new DeserializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build();
+ var config = deserializer.Deserialize(File.ReadAllText(path))
+ ?? new AppMapConfig();
+ config.Name ??= AppNameFallback();
+ config.BaseDirectory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!;
+ Logger.Debug($"loaded config from {path}: name={config.Name}, "
+ + $"{config.Packages.Count} package(s)");
+ return config;
+ }
+ catch (Exception e)
+ {
+ Logger.Warn($"failed to parse {path}: {e.Message}; using empty config");
+ return new AppMapConfig { Name = AppNameFallback() };
+ }
+ }
+
+ private static string? FindConfigFile()
+ {
+ var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
+ while (dir != null)
+ {
+ var candidate = System.IO.Path.Combine(dir.FullName, "appmap.yml");
+ if (File.Exists(candidate))
+ return candidate;
+ dir = dir.Parent;
+ }
+ return null;
+ }
+
+ private static string AppNameFallback() =>
+ System.IO.Path.GetFileName(Directory.GetCurrentDirectory());
+}
+
+///
+/// One entry of the packages: list. path is a namespace (or namespace
+/// prefix) such as MyApp.Services; exclude entries are
+/// fully-qualified-name prefixes carved back out of the package.
+///
+public sealed class AppMapPackage
+{
+ [YamlMember(Alias = "path")]
+ public string? Path { get; set; }
+
+ [YamlMember(Alias = "exclude")]
+ public List Exclude { get; set; } = new();
+
+ [YamlMember(Alias = "shallow")]
+ public bool Shallow { get; set; }
+
+ [YamlMember(Alias = "methods")]
+ public List? Methods { get; set; }
+
+ public bool Matches(string fullyQualifiedName)
+ {
+ if (Path == null || !IsPrefix(Path, fullyQualifiedName))
+ return false;
+
+ if (Methods != null)
+ return Methods.Any(m => m.Matches(fullyQualifiedName));
+
+ return !Exclude.Any(e => IsPrefix(e, fullyQualifiedName));
+ }
+
+ /// Labels contributed by a matching methods: entry, if any.
+ public IReadOnlyList? LabelsFor(string fullyQualifiedName) =>
+ Methods?.FirstOrDefault(m => m.Matches(fullyQualifiedName))?.Labels;
+
+ private static bool IsPrefix(string prefix, string name) =>
+ name == prefix || name.StartsWith(prefix + ".", StringComparison.Ordinal);
+}
+
+/// A methods: entry — regex match on class and method name.
+public sealed class MethodConfig
+{
+ [YamlMember(Alias = "class")]
+ public string? Class { get; set; }
+
+ [YamlMember(Alias = "name")]
+ public string? Name { get; set; }
+
+ [YamlMember(Alias = "labels")]
+ public List Labels { get; set; } = new();
+
+ public bool Matches(string fullyQualifiedName)
+ {
+ var lastDot = fullyQualifiedName.LastIndexOf('.');
+ if (lastDot < 0)
+ return false;
+ var className = fullyQualifiedName.Substring(0, lastDot);
+ var methodName = fullyQualifiedName.Substring(lastDot + 1);
+
+ if (Class != null && !System.Text.RegularExpressions.Regex.IsMatch(className, Class))
+ return false;
+ return Name == null || System.Text.RegularExpressions.Regex.IsMatch(methodName, Name);
+ }
+}
diff --git a/managed/src/AppMap.Agent/Config/Properties.cs b/managed/src/AppMap.Agent/Config/Properties.cs
new file mode 100644
index 0000000..4b1c3bb
--- /dev/null
+++ b/managed/src/AppMap.Agent/Config/Properties.cs
@@ -0,0 +1,58 @@
+namespace AppMap.Config;
+
+///
+/// Environment-variable knobs, mirroring com.appland.appmap.config.Properties
+/// in appmap-java. All values are read lazily so tests can mutate the
+/// environment.
+///
+public static class Properties
+{
+ private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
+
+ private static bool Flag(string name, bool defaultValue)
+ {
+ var v = Env(name);
+ if (string.IsNullOrEmpty(v))
+ return defaultValue;
+ return v is "true" or "1" or "yes" or "on";
+ }
+
+ /// APPMAP_CONFIG_FILE: explicit path to appmap.yml.
+ public static string? ConfigFile => Env("APPMAP_CONFIG_FILE");
+
+ /// APPMAP_OUTPUT_DIRECTORY: overrides appmap_dir from appmap.yml.
+ public static string? OutputDirectory => Env("APPMAP_OUTPUT_DIRECTORY");
+
+ /// APPMAP_RECORDING_REMOTE: serve /_appmap/record endpoints (default true).
+ public static bool RecordingRemote => Flag("APPMAP_RECORDING_REMOTE", true);
+
+ /// APPMAP_RECORDING_REQUESTS: record one AppMap per HTTP request (default true).
+ public static bool RecordingRequests => Flag("APPMAP_RECORDING_REQUESTS", true);
+
+ /// APPMAP_RECORD_PROCESS: record the whole process, written at exit (default false).
+ public static bool RecordingProcess => Flag("APPMAP_RECORD_PROCESS", false);
+
+ /// APPMAP_RECORD_PRIVATE: instrument private methods too (default false).
+ public static bool RecordPrivate => Flag("APPMAP_RECORD_PRIVATE", false);
+
+ /// APPMAP_EVENT_DISABLEVALUE: never stringify parameter/return values.
+ public static bool DisableValue => Flag("APPMAP_EVENT_DISABLEVALUE", false);
+
+ /// APPMAP_EVENT_VALUESIZE: max length of a captured value string (default 1024, -1 unlimited).
+ public static int MaxValueSize =>
+ int.TryParse(Env("APPMAP_EVENT_VALUESIZE"), out var n) ? n : 1024;
+
+ /// APPMAP_DEFAULT_EXCLUDES: skip noise methods (Equals, GetHashCode,
+ /// ToString, ..., EF migrations) by default (default true).
+ public static bool DefaultExcludes => Flag("APPMAP_DEFAULT_EXCLUDES", true);
+
+ /// APPMAP_RECORD_ASYNC: emit an async method's return event when
+ /// its Task completes, not when the Task is returned (default true).
+ public static bool RecordAsync => Flag("APPMAP_RECORD_ASYNC", true);
+
+ /// APPMAP_DEBUG: log agent diagnostics to stderr.
+ public static bool Debug => Flag("APPMAP_DEBUG", false);
+
+ /// APPMAP_DEBUG_DISABLEGIT: skip git metadata collection.
+ public static bool DisableGit => Flag("APPMAP_DEBUG_DISABLEGIT", false);
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/AsyncResult.cs b/managed/src/AppMap.Agent/Instrumentation/AsyncResult.cs
new file mode 100644
index 0000000..cc0959d
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/AsyncResult.cs
@@ -0,0 +1,84 @@
+using System.Reflection;
+
+namespace AppMap.Instrumentation;
+
+///
+/// Helpers for recording the completion of an awaitable return value. An
+/// async method patched by Harmony returns its Task at the first incomplete
+/// await, not when the work finishes — so recording the return eagerly gives
+/// the wrong elapsed time and a Task instead of the real value. Instead the
+/// finalizer hands the Task here and the return event is emitted when it
+/// completes (full appmap-java parity on per-await splitting is still future
+/// work; this fixes timing and the unwrapped value/exception).
+///
+public static class AsyncResult
+{
+ ///
+ /// If is an awaitable (Task, Task<T>,
+ /// ValueTask, ValueTask<T>), returns the underlying Task; otherwise
+ /// null. ValueTasks are converted with AsTask().
+ ///
+ public static Task? AsTask(object? result)
+ {
+ switch (result)
+ {
+ case null:
+ return null;
+ case Task task:
+ return task;
+ }
+
+ var type = result.GetType();
+ if (!type.IsGenericType && type != typeof(ValueTask))
+ return null;
+
+ var name = type.Namespace == "System.Threading.Tasks" ? type.Name : null;
+ if (name != "ValueTask" && name != "ValueTask`1")
+ return null;
+ try
+ {
+ // ValueTask / ValueTask both expose AsTask().
+ return type.GetMethod("AsTask", BindingFlags.Public | BindingFlags.Instance,
+ null, Type.EmptyTypes, null)?.Invoke(result, null) as Task;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// The completed task's result and its declared element type, plus any
+ /// fault. For a non-generic Task the value is null. The fault is
+ /// unwrapped from AggregateException to the first inner exception, the
+ /// same shape a synchronous throw would have produced.
+ ///
+ public static (object? Value, Type? ValueType, Exception? Exception) Unwrap(Task task)
+ {
+ if (task.IsFaulted)
+ {
+ var ex = task.Exception?.InnerExceptions.Count == 1
+ ? task.Exception.InnerExceptions[0]
+ : task.Exception;
+ return (null, null, ex);
+ }
+ if (task.IsCanceled)
+ return (null, null, new TaskCanceledException(task));
+
+ // The runtime type is often a Task subclass (e.g. the async state
+ // machine box), so find Task by walking the base chain.
+ for (var t = task.GetType(); t != null && t != typeof(object); t = t.BaseType)
+ {
+ if (!t.IsGenericType || t.GetGenericTypeDefinition() != typeof(Task<>))
+ continue;
+ // Task is the boxed form of a non-generic await;
+ // it has no meaningful value.
+ var elementType = t.GetGenericArguments()[0];
+ if (elementType.Name == "VoidTaskResult")
+ return (null, null, null);
+ var value = t.GetProperty("Result")?.GetValue(task);
+ return (value, value?.GetType() ?? elementType, null);
+ }
+ return (null, null, null);
+ }
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/AttributeLabels.cs b/managed/src/AppMap.Agent/Instrumentation/AttributeLabels.cs
new file mode 100644
index 0000000..96c1a1c
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/AttributeLabels.cs
@@ -0,0 +1,82 @@
+using System.Reflection;
+
+namespace AppMap.Instrumentation;
+
+///
+/// Reads [AppMap.Labels(...)] from a method and its declaring type — the
+/// analog of appmap-java's @Labels handling. The attribute is matched by
+/// full type name rather than assembly identity, so applications may
+/// reference any version of AppMap.Attributes (or define a compatible
+/// attribute themselves).
+///
+public static class AttributeLabels
+{
+ public const string AttributeFullName = "AppMap.LabelsAttribute";
+
+ ///
+ /// Labels declared on the method plus those on its declaring type, in
+ /// declaration order with duplicates removed; null when neither carries
+ /// the attribute.
+ ///
+ public static IReadOnlyList? Of(MethodBase method)
+ {
+ var labels = Read(method.DeclaringType, null);
+ labels = Read(method, labels);
+ return labels;
+ }
+
+ private static List? Read(MemberInfo? member, List? labels)
+ {
+ if (member == null)
+ return labels;
+
+ IList attributes;
+ try
+ {
+ // GetCustomAttributesData avoids instantiating the attribute, so
+ // a name match never requires loading AppMap.Attributes itself.
+ attributes = member.GetCustomAttributesData();
+ }
+ catch
+ {
+ return labels;
+ }
+
+ foreach (var data in attributes)
+ {
+ if (data.AttributeType.FullName != AttributeFullName)
+ continue;
+ if (data.ConstructorArguments.Count != 1
+ || data.ConstructorArguments[0].Value
+ is not IReadOnlyCollection items)
+ continue;
+ foreach (var item in items)
+ {
+ if (item.Value is string label && label.Length > 0)
+ {
+ labels ??= new List();
+ if (!labels.Contains(label))
+ labels.Add(label);
+ }
+ }
+ }
+ return labels;
+ }
+
+ /// Merges config-supplied labels with attribute labels.
+ public static IReadOnlyList? Merge(
+ IReadOnlyList? first, IReadOnlyList? second)
+ {
+ if (first is not { Count: > 0 })
+ return second;
+ if (second is not { Count: > 0 })
+ return first;
+ var merged = new List(first);
+ foreach (var label in second)
+ {
+ if (!merged.Contains(label))
+ merged.Add(label);
+ }
+ return merged;
+ }
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/BuiltinHooks.cs b/managed/src/AppMap.Agent/Instrumentation/BuiltinHooks.cs
new file mode 100644
index 0000000..8a42e04
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/BuiltinHooks.cs
@@ -0,0 +1,236 @@
+using System.Reflection;
+using AppMap.Util;
+using HarmonyLib;
+
+namespace AppMap.Instrumentation;
+
+///
+/// One built-in hook: a framework type (matched by exact name, base class,
+/// or implemented interface) whose named methods are recorded with the given
+/// labels regardless of the packages: configuration.
+///
+public sealed class HookRule
+{
+ /// Full name of the type, base class, or interface to match.
+ public required string Type { get; init; }
+
+ public required string[] Methods { get; init; }
+
+ public required string[] Labels { get; init; }
+
+ public bool Matches(Type type)
+ {
+ for (var t = type; t != null; t = t.BaseType)
+ {
+ if (t.FullName == Type)
+ return true;
+ }
+ foreach (var i in type.GetInterfaces())
+ {
+ if (i.FullName == Type)
+ return true;
+ }
+ return false;
+ }
+}
+
+///
+/// Pre-labeled hooks for framework code — the analog of appmap-java's
+/// bundled hook definitions for the JDK, Spring Security, slf4j, Jackson,
+/// etc. AppMap runtime analysis rules match on these function labels
+/// (security.authentication, crypto.*, deserialize.unsafe, log, ...), so
+/// they are instrumented even though they fall outside the application's
+/// packages: configuration. Provider assemblies loaded later are caught by
+/// the AssemblyLoad handler, as in SqlHooks.
+///
+public static class BuiltinHooks
+{
+ ///
+ /// Label taxonomy follows appmap-java's hooks so existing AppMap
+ /// analysis rules apply unchanged.
+ ///
+ public static readonly IReadOnlyList Rules = new HookRule[]
+ {
+ // Logging — powers e.g. the secret-in-log analysis. App code logs
+ // through the static LoggerExtensions methods, which are non-generic
+ // and so patchable (ILogger.Log itself is an open generic).
+ new()
+ {
+ Type = "Microsoft.Extensions.Logging.LoggerExtensions",
+ Methods = new[] { "Log", "LogTrace", "LogDebug", "LogInformation",
+ "LogWarning", "LogError", "LogCritical" },
+ Labels = new[] { "log" },
+ },
+
+ // Authentication / authorization (ASP.NET Core). SignInManager is
+ // an open generic type, so hook the non-generic services beneath it.
+ new()
+ {
+ Type = "Microsoft.AspNetCore.Authentication.IAuthenticationService",
+ Methods = new[] { "AuthenticateAsync", "SignInAsync", "SignOutAsync" },
+ Labels = new[] { "security.authentication" },
+ },
+ new()
+ {
+ Type = "Microsoft.AspNetCore.Authorization.IAuthorizationService",
+ Methods = new[] { "AuthorizeAsync" },
+ Labels = new[] { "security.authorization" },
+ },
+
+ // Cryptography. ComputeHash lives concrete on the abstract
+ // HashAlgorithm base, so the base-class match patches it once.
+ new()
+ {
+ Type = "System.Security.Cryptography.SymmetricAlgorithm",
+ Methods = new[] { "CreateEncryptor" },
+ Labels = new[] { "crypto.encrypt" },
+ },
+ new()
+ {
+ Type = "System.Security.Cryptography.SymmetricAlgorithm",
+ Methods = new[] { "CreateDecryptor" },
+ Labels = new[] { "crypto.decrypt" },
+ },
+ new()
+ {
+ Type = "System.Security.Cryptography.HashAlgorithm",
+ Methods = new[] { "ComputeHash", "ComputeHashAsync" },
+ Labels = new[] { "crypto.digest" },
+ },
+
+ // Serialization — deserialize.unsafe powers the
+ // deserialization-of-untrusted-data finding.
+ new()
+ {
+ Type = "System.Runtime.Serialization.Formatters.Binary.BinaryFormatter",
+ Methods = new[] { "Deserialize", "UnsafeDeserialize" },
+ Labels = new[] { "deserialize.unsafe" },
+ },
+ // System.Text.Json.JsonSerializer.Deserialize is deliberately not
+ // hooked: its overloads are generic or [RequiresDynamicCode]
+ // intrinsics that Harmony cannot patch, and JSON is a low-risk
+ // deserialization sink anyway. Newtonsoft and XML stay.
+ new()
+ {
+ Type = "Newtonsoft.Json.JsonConvert",
+ Methods = new[] { "DeserializeObject" },
+ Labels = new[] { "deserialize" },
+ },
+ // XmlSerializer.Deserialize is the classic XXE / unsafe-XML sink.
+ new()
+ {
+ Type = "System.Xml.Serialization.XmlSerializer",
+ Methods = new[] { "Deserialize" },
+ Labels = new[] { "deserialize" },
+ },
+ new()
+ {
+ Type = "System.Runtime.Serialization.DataContractSerializer",
+ Methods = new[] { "ReadObject" },
+ Labels = new[] { "deserialize" },
+ },
+
+ // NOTE: RandomNumberGenerator (random.secure) and AsymmetricAlgorithm
+ // Sign/Verify (crypto.sign/verify) were tried here but their methods
+ // on the abstract BCL crypto bases are intrinsic-backed and make
+ // Harmony throw InvalidProgramException at patch time (safely caught,
+ // but noisy). They need concrete-type targeting first — see BACKLOG.
+
+ // Outbound HTTP — the analog of appmap-java's HTTP client hooks
+ // (http_client_request is a future event type; the label lets
+ // analysis find external calls today).
+ new()
+ {
+ Type = "System.Net.Http.HttpClient",
+ Methods = new[] { "SendAsync", "Send" },
+ Labels = new[] { "http.client.request" },
+ },
+
+ // HTTP session (ASP.NET Core).
+ new()
+ {
+ Type = "Microsoft.AspNetCore.Http.ISession",
+ Methods = new[] { "TryGetValue" },
+ Labels = new[] { "http.session.read" },
+ },
+ new()
+ {
+ Type = "Microsoft.AspNetCore.Http.ISession",
+ Methods = new[] { "Set", "Remove", "Clear" },
+ Labels = new[] { "http.session.write" },
+ },
+
+ // Background jobs.
+ new()
+ {
+ Type = "Hangfire.IBackgroundJobClient",
+ Methods = new[] { "Create" },
+ Labels = new[] { "job.create" },
+ },
+ };
+
+ private static readonly Harmony harmony = new("com.appland.appmap.builtin");
+ private static readonly HashSet seen = new();
+ private static readonly object gate = new();
+
+ public static void Install()
+ {
+ AppDomain.CurrentDomain.AssemblyLoad += (_, args) => Scan(args.LoadedAssembly);
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ Scan(assembly);
+ }
+
+ private static void Scan(Assembly assembly)
+ {
+ lock (gate)
+ {
+ if (!seen.Add(assembly))
+ return;
+ }
+ if (assembly.IsDynamic || assembly == typeof(BuiltinHooks).Assembly)
+ return;
+
+ Type[] types;
+ try
+ {
+ types = assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException e)
+ {
+ types = e.Types.Where(t => t != null).ToArray()!;
+ }
+ catch
+ {
+ return;
+ }
+
+ var patched = 0;
+ foreach (var type in types)
+ {
+ // Open generic types cannot be patched (Harmony limitation).
+ if (!type.IsClass || type.IsGenericTypeDefinition)
+ continue;
+ foreach (var rule in Rules)
+ {
+ if (!rule.Matches(type))
+ continue;
+ foreach (var name in rule.Methods)
+ {
+ foreach (var method in type.GetMethods(BindingFlags.Public
+ | BindingFlags.NonPublic | BindingFlags.Instance
+ | BindingFlags.Static | BindingFlags.DeclaredOnly))
+ {
+ if (method.Name != name || method.IsAbstract
+ || method.ContainsGenericParameters
+ || method.GetMethodBody() == null)
+ continue;
+ if (HookPatcher.TryPatch(harmony, method, rule.Labels))
+ patched++;
+ }
+ }
+ }
+ }
+ if (patched > 0)
+ Logger.Debug($"built-in hooks: {patched} method(s) in {assembly.GetName().Name}");
+ }
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/EventTemplateRegistry.cs b/managed/src/AppMap.Agent/Instrumentation/EventTemplateRegistry.cs
new file mode 100644
index 0000000..f79280d
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/EventTemplateRegistry.cs
@@ -0,0 +1,113 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using AppMap.Output;
+using AppMap.Util;
+
+namespace AppMap.Instrumentation;
+
+///
+/// Per-method immutable facts gathered once at patch time — the analog of
+/// appmap-java's EventTemplateRegistry, which caches event templates built
+/// from bytecode so the hot path only clones them.
+///
+public sealed class MethodTemplate
+{
+ public required string DefinedClass { get; init; }
+ public required string MethodId { get; init; }
+ public required bool IsStatic { get; init; }
+ public string? Path { get; init; }
+ public int? LineNo { get; init; }
+ public required string NamespaceName { get; init; }
+ public required IReadOnlyList ClassChain { get; init; }
+ public required IReadOnlyList Parameters { get; init; }
+ public IReadOnlyList? Labels { get; init; }
+
+ public Event BuildCallEvent(object? instance, object?[]? args)
+ {
+ var e = new Event
+ {
+ EventType = "call",
+ DefinedClass = DefinedClass,
+ MethodId = MethodId,
+ Static = IsStatic,
+ Path = Path,
+ LineNo = LineNo,
+ };
+ if (!IsStatic && instance != null)
+ e.Receiver = Value.Capture(instance, kind: "req");
+ if (args != null)
+ {
+ e.Parameters = new List(args.Length);
+ for (var i = 0; i < args.Length; i++)
+ {
+ var p = i < Parameters.Count ? Parameters[i] : null;
+ e.Parameters.Add(Value.Capture(args[i],
+ name: p?.Name ?? $"arg{i}",
+ declaredType: p?.ParameterType,
+ kind: "req"));
+ }
+ }
+ return e;
+ }
+
+ public Event BuildReturnEvent(int parentId, double elapsedSeconds,
+ object? result, Type? returnType, Exception? exception)
+ {
+ var e = new Event
+ {
+ EventType = "return",
+ ParentId = parentId,
+ Elapsed = elapsedSeconds,
+ };
+ if (exception != null)
+ e.Exceptions = ExceptionValue.ChainOf(exception);
+ else if (returnType != null && returnType != typeof(void))
+ e.ReturnValue = Value.Capture(result, declaredType: returnType);
+ return e;
+ }
+
+ public void RegisterCodeObject(CodeObjectTree tree) =>
+ tree.RegisterFunction(NamespaceName, ClassChain, MethodId, IsStatic,
+ Path != null && LineNo.HasValue ? $"{Path}:{LineNo}" : Path, Labels);
+}
+
+public static class EventTemplateRegistry
+{
+ private static readonly ConcurrentDictionary templates = new();
+
+ public static MethodTemplate? Get(MethodBase method) =>
+ templates.TryGetValue(method, out var t) ? t : null;
+
+ /// True once a method has been registered (and so patched);
+ /// used to keep the config-driven instrumentor and the built-in hooks
+ /// from double-patching the same method.
+ public static bool IsRegistered(MethodBase method) => templates.ContainsKey(method);
+
+ public static MethodTemplate Register(MethodBase method, IReadOnlyList? labels)
+ {
+ return templates.GetOrAdd(method, m =>
+ {
+ var type = m.DeclaringType!;
+ var (path, lineno) = SourceLocator.Locate(m);
+
+ // Nested types come back as Outer+Inner; the class_map wants the
+ // chain, defined_class wants dots.
+ var classChain = new List();
+ for (var t = type; t != null; t = t.DeclaringType)
+ classChain.Insert(0, t.Name);
+
+ return new MethodTemplate
+ {
+ DefinedClass = Value.TypeName(type),
+ MethodId = m.IsConstructor ? (m.IsStatic ? ".cctor" : ".ctor") : m.Name,
+ IsStatic = m.IsStatic,
+ Path = path,
+ LineNo = lineno,
+ NamespaceName = type.Namespace ?? string.Empty,
+ ClassChain = classChain,
+ Parameters = m.GetParameters(),
+ Labels = labels,
+ };
+ });
+ }
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/HookPatcher.cs b/managed/src/AppMap.Agent/Instrumentation/HookPatcher.cs
new file mode 100644
index 0000000..cf641cd
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/HookPatcher.cs
@@ -0,0 +1,46 @@
+using System.Reflection;
+using AppMap.Util;
+using HarmonyLib;
+
+namespace AppMap.Instrumentation;
+
+///
+/// Applies the MethodHooks prefix/finalizer pair to a method and registers
+/// its event template — shared by the config-driven Instrumentor and the
+/// built-in framework hooks.
+///
+internal static class HookPatcher
+{
+ ///
+ /// Patches the method unless it was already patched by another hook
+ /// source. Returns true when this call performed the patch.
+ ///
+ public static bool TryPatch(Harmony harmony, MethodBase method,
+ IReadOnlyList? labels)
+ {
+ if (EventTemplateRegistry.IsRegistered(method))
+ return false;
+ try
+ {
+ var isVoid = method is MethodInfo { ReturnType.FullName: "System.Void" }
+ || method.IsConstructor;
+ var finalizer = isVoid ? nameof(MethodHooks.FinalizerVoid) : nameof(MethodHooks.Finalizer);
+ EventTemplateRegistry.Register(method, labels);
+ harmony.Patch(method,
+ prefix: new HarmonyMethod(typeof(MethodHooks), nameof(MethodHooks.Prefix)),
+ finalizer: new HarmonyMethod(typeof(MethodHooks), finalizer));
+ return true;
+ }
+ catch (Exception e)
+ {
+ // Some methods (JIT intrinsics, [RequiresDynamicCode] BCL
+ // helpers) cannot be rewritten and make Harmony throw; the
+ // method is simply left uninstrumented. Phrased as a skip so the
+ // CLR's "invalid program" wording does not read like the agent
+ // broke something.
+ Logger.Debug($"skipping {method.DeclaringType?.Name}.{method.Name} "
+ + $"(not instrumentable: {e.GetType().Name})");
+ return false;
+ }
+ }
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/Instrumentor.cs b/managed/src/AppMap.Agent/Instrumentation/Instrumentor.cs
new file mode 100644
index 0000000..013ca73
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/Instrumentor.cs
@@ -0,0 +1,168 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using AppMap.Config;
+using AppMap.Util;
+using HarmonyLib;
+
+namespace AppMap.Instrumentation;
+
+///
+/// Selects and patches application methods according to appmap.yml — the
+/// counterpart of appmap-java's ClassFileTransformer + ConfigCondition,
+/// using Harmony runtime patching instead of load-time bytecode rewriting.
+/// Assemblies loaded after startup are picked up via AssemblyLoad.
+///
+public sealed class Instrumentor
+{
+ private readonly Harmony harmony = new("com.appland.appmap");
+ private readonly AppMapConfig config;
+ private readonly HashSet instrumented = new();
+ private readonly object gate = new();
+
+ public Instrumentor(AppMapConfig config) => this.config = config;
+
+ public void Start()
+ {
+ // Even with no packages: configured, assemblies may opt methods in
+ // with [AppMap.Labels]; the per-assembly reference check keeps the
+ // scan cheap in that case.
+ AppDomain.CurrentDomain.AssemblyLoad += (_, args) => InstrumentAssembly(args.LoadedAssembly);
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ InstrumentAssembly(assembly);
+ }
+
+ private void InstrumentAssembly(Assembly assembly)
+ {
+ lock (gate)
+ {
+ if (!instrumented.Add(assembly))
+ return;
+ }
+ if (assembly.IsDynamic || assembly == typeof(Instrumentor).Assembly)
+ return;
+
+ Type[] types;
+ try
+ {
+ types = assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException e)
+ {
+ types = e.Types.Where(t => t != null).ToArray()!;
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"cannot inspect {assembly.GetName().Name}: {e.Message}");
+ return;
+ }
+
+ // Only assemblies that reference AppMap.Attributes (or define the
+ // attribute themselves) can carry [AppMap.Labels]; checking once per
+ // assembly keeps attribute probing off the common path.
+ var mayHaveLabels = ReferencesLabelsAttribute(assembly);
+ if (config.Packages.Count == 0 && !mayHaveLabels)
+ return;
+
+ var patched = 0;
+ foreach (var type in types)
+ {
+ if (!IsInstrumentableType(type))
+ continue;
+ foreach (var method in CandidateMethods(type))
+ {
+ var fqn = $"{Output.Value.TypeName(type)}.{method.Name}";
+ var package = config.FindPackage(fqn);
+ // [AppMap.Labels] opts a method in even when its namespace
+ // is not listed under packages:, as @Labels does in
+ // appmap-java.
+ var attributeLabels = mayHaveLabels ? AttributeLabels.Of(method) : null;
+ if (package == null && attributeLabels == null)
+ continue;
+ var labels = AttributeLabels.Merge(package?.LabelsFor(fqn), attributeLabels);
+ if (HookPatcher.TryPatch(harmony, method, labels))
+ patched++;
+ }
+ }
+ if (patched > 0)
+ Logger.Debug($"instrumented {patched} method(s) in {assembly.GetName().Name}");
+ }
+
+ private static bool ReferencesLabelsAttribute(Assembly assembly)
+ {
+ if (assembly.GetReferencedAssemblies().Any(a => a.Name == "AppMap.Attributes"))
+ return true;
+ // Compatible attributes may also be declared in the assembly itself.
+ return assembly.GetType(AttributeLabels.AttributeFullName, false) != null;
+ }
+
+ private static bool IsInstrumentableType(Type type)
+ {
+ if (!type.IsClass || type.IsGenericTypeDefinition)
+ return false;
+ // Skip compiler artifacts: closures, async state machines, etc.
+ if (type.Name.Contains('<') || type.IsDefined(typeof(CompilerGeneratedAttribute), false))
+ return false;
+ return true;
+ }
+
+ private IEnumerable CandidateMethods(Type type)
+ {
+ var visibility = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static
+ | BindingFlags.DeclaredOnly;
+ if (Properties.RecordPrivate)
+ visibility |= BindingFlags.NonPublic;
+
+ var methods = type.GetMethods(visibility).Cast()
+ .Concat(type.GetConstructors(visibility & ~BindingFlags.Static));
+
+ foreach (var method in methods)
+ {
+ if (method.IsAbstract || method.ContainsGenericParameters)
+ continue;
+ if (method.GetMethodBody() == null)
+ continue;
+ // Property accessors and other compiler-generated bodies are
+ // trivial noise (the Java agent filters these too).
+ if (method.IsDefined(typeof(CompilerGeneratedAttribute), false))
+ continue;
+ if (method.IsSpecialName && (method.Name.StartsWith("get_") || method.Name.StartsWith("set_")
+ || method.Name.StartsWith("add_") || method.Name.StartsWith("remove_")
+ || method.Name.StartsWith("op_")))
+ continue;
+ if (Properties.DefaultExcludes && IsDefaultExcluded(method))
+ continue;
+ yield return method;
+ }
+ }
+
+ ///
+ /// Methods skipped by default to keep recordings readable — the analog
+ /// of appmap-java ignoring equals/hashCode/toString and friends. Opt out
+ /// with APPMAP_DEFAULT_EXCLUDES=false. [AppMap.Labels] still wins: a
+ /// labeled method is recorded regardless.
+ ///
+ public static bool IsDefaultExcluded(MethodBase method)
+ {
+ if (AttributeLabels.Of(method) != null)
+ return false;
+
+ // EF Core migrations and model snapshots are generated scaffolding.
+ var ns = method.DeclaringType?.Namespace;
+ if (ns != null && (ns == "Migrations" || ns.EndsWith(".Migrations", StringComparison.Ordinal)))
+ return true;
+
+ switch (method.Name)
+ {
+ case "Equals":
+ case "GetHashCode":
+ case "ToString":
+ case "CompareTo":
+ case "Deconstruct":
+ case "Finalize":
+ case "Dispose" when method.GetParameters().Length == 0:
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/MethodHooks.cs b/managed/src/AppMap.Agent/Instrumentation/MethodHooks.cs
new file mode 100644
index 0000000..211f46b
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/MethodHooks.cs
@@ -0,0 +1,140 @@
+using System.Diagnostics;
+using System.Reflection;
+using AppMap.Record;
+
+namespace AppMap.Instrumentation;
+
+///
+/// The Harmony prefix/finalizer pair applied to every instrumented method —
+/// the runtime half of what appmap-java injects with Javassist. The prefix
+/// emits the "call" event; the finalizer (which runs on both normal and
+/// exceptional exit) emits the matching "return" event.
+///
+public static class MethodHooks
+{
+ private sealed class CallContext
+ {
+ public required MethodTemplate Template { get; init; }
+ public required int CallEventId { get; init; }
+ public required long StartTimestamp { get; init; }
+ }
+
+ // Guards against the recorder's own code (ToString calls, file IO inside
+ // patched assemblies, ...) re-entering the hooks. Same role as the Java
+ // agent's ThreadLock.
+ [ThreadStatic]
+ private static bool inHook;
+
+ public static void Prefix(MethodBase __originalMethod, object? __instance,
+ object?[]? __args, ref object? __state)
+ {
+ __state = null;
+ if (inHook || !Recorder.Instance.HasActiveSession)
+ return;
+ inHook = true;
+ try
+ {
+ var template = EventTemplateRegistry.Get(__originalMethod);
+ if (template == null)
+ return;
+ var callEvent = template.BuildCallEvent(__instance, __args);
+ Recorder.Instance.Add(callEvent, template.RegisterCodeObject);
+ __state = new CallContext
+ {
+ Template = template,
+ CallEventId = callEvent.Id,
+ StartTimestamp = Stopwatch.GetTimestamp(),
+ };
+ }
+ catch (Exception e)
+ {
+ Util.Logger.Error("call hook failed", e);
+ }
+ finally
+ {
+ inHook = false;
+ }
+ }
+
+ /// Finalizer for methods with a return value.
+ public static Exception? Finalizer(MethodBase __originalMethod, object? __result,
+ Exception? __exception, object? __state)
+ {
+ Record(__originalMethod, __result, __exception, __state);
+ return __exception;
+ }
+
+ /// Finalizer for void methods (Harmony forbids __result there).
+ public static Exception? FinalizerVoid(MethodBase __originalMethod,
+ Exception? __exception, object? __state)
+ {
+ Record(__originalMethod, null, __exception, __state);
+ return __exception;
+ }
+
+ private static void Record(MethodBase method, object? result,
+ Exception? exception, object? state)
+ {
+ if (state is not CallContext ctx || inHook)
+ return;
+
+ // For an awaited method that returned a Task, defer the return event
+ // until the Task completes so elapsed and the value are real. A
+ // method that threw synchronously is recorded immediately.
+ if (exception == null && Config.Properties.RecordAsync
+ && AsyncResult.AsTask(result) is { } task)
+ {
+ RecordWhenComplete(ctx, task);
+ return;
+ }
+
+ inHook = true;
+ try
+ {
+ var elapsed = Elapsed(ctx);
+ var returnType = (method as MethodInfo)?.ReturnType;
+ var returnEvent = ctx.Template.BuildReturnEvent(
+ ctx.CallEventId, elapsed, result, returnType, exception);
+ Recorder.Instance.Add(returnEvent);
+ }
+ catch (Exception e)
+ {
+ Util.Logger.Error("return hook failed", e);
+ }
+ finally
+ {
+ inHook = false;
+ }
+ }
+
+ private static void RecordWhenComplete(CallContext ctx, Task task)
+ {
+ // ContinueWith flows ExecutionContext, so the AsyncLocal request
+ // session is still visible when the continuation runs.
+ task.ContinueWith(t =>
+ {
+ if (inHook)
+ return;
+ inHook = true;
+ try
+ {
+ var elapsed = Elapsed(ctx);
+ var (value, valueType, exception) = AsyncResult.Unwrap(t);
+ var returnEvent = ctx.Template.BuildReturnEvent(
+ ctx.CallEventId, elapsed, value, valueType, exception);
+ Recorder.Instance.Add(returnEvent);
+ }
+ catch (Exception e)
+ {
+ Util.Logger.Error("async return hook failed", e);
+ }
+ finally
+ {
+ inHook = false;
+ }
+ }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
+ }
+
+ private static double Elapsed(CallContext ctx) =>
+ (Stopwatch.GetTimestamp() - ctx.StartTimestamp) / (double)Stopwatch.Frequency;
+}
diff --git a/managed/src/AppMap.Agent/Instrumentation/SqlHooks.cs b/managed/src/AppMap.Agent/Instrumentation/SqlHooks.cs
new file mode 100644
index 0000000..230c858
--- /dev/null
+++ b/managed/src/AppMap.Agent/Instrumentation/SqlHooks.cs
@@ -0,0 +1,203 @@
+using System.Data.Common;
+using System.Diagnostics;
+using System.Reflection;
+using AppMap.Output;
+using AppMap.Record;
+using AppMap.Util;
+using HarmonyLib;
+
+namespace AppMap.Instrumentation;
+
+///
+/// Records sql_query events by patching the Execute* overrides of every
+/// concrete DbCommand implementation found in the process — the .NET analog
+/// of appmap-java's JDBC Statement hooks. Provider assemblies loaded later
+/// are caught by the AssemblyLoad handler.
+///
+public static class SqlHooks
+{
+ private static readonly Harmony harmony = new("com.appland.appmap.sql");
+ private static readonly HashSet seen = new();
+ private static readonly object gate = new();
+
+ private static readonly string[] ExecuteMethods =
+ {
+ "ExecuteNonQuery", "ExecuteScalar", "ExecuteDbDataReader",
+ "ExecuteNonQueryAsync", "ExecuteScalarAsync", "ExecuteDbDataReaderAsync",
+ };
+
+ public static void Install()
+ {
+ AppDomain.CurrentDomain.AssemblyLoad += (_, args) => Scan(args.LoadedAssembly);
+ foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+ Scan(assembly);
+ }
+
+ ///
+ /// The non-null types from a partial load. A provider assembly that fails
+ /// to fully load (e.g. Microsoft.Data.SqlClient on Linux, with one
+ /// unloadable type) must not cause every loadable type — including
+ /// SqlCommand — to be discarded, or no SQL is ever recorded. Exposed for
+ /// tests; the failure is environment-specific so this guards the logic.
+ ///
+ internal static Type[] LoadableTypes(ReflectionTypeLoadException e) =>
+ e.Types.Where(t => t is not null).Cast().ToArray();
+
+ private static void Scan(Assembly assembly)
+ {
+ lock (gate)
+ {
+ if (!seen.Add(assembly))
+ return;
+ }
+ if (assembly.IsDynamic)
+ return;
+
+ // Cheap pre-filter: only providers reference System.Data.Common.
+ if (!assembly.GetReferencedAssemblies().Any(a =>
+ a.Name is "System.Data.Common" or "System.Data" or "netstandard"))
+ return;
+
+ Type[] types;
+ try
+ {
+ types = assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException e)
+ {
+ // Providers are routinely only partially loadable (optional
+ // dependencies the app doesn't ship); patch the types that did
+ // load instead of bailing. Microsoft.Data.SqlClient on Linux
+ // hits this, and SqlCommand itself loads fine.
+ types = LoadableTypes(e);
+ Logger.Debug($"partial type load in {assembly.GetName().Name}: "
+ + $"{e.LoaderExceptions.Length} loader error(s), "
+ + $"{types.Length} usable type(s)");
+ }
+ catch
+ {
+ return;
+ }
+
+ foreach (var type in types)
+ {
+ if (type.IsAbstract || !typeof(DbCommand).IsAssignableFrom(type))
+ continue;
+ foreach (var name in ExecuteMethods)
+ {
+ var method = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic
+ | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+ .FirstOrDefault(m => m.Name == name && !m.ContainsGenericParameters);
+ if (method == null || method.GetMethodBody() == null)
+ continue;
+ try
+ {
+ harmony.Patch(method,
+ prefix: new HarmonyMethod(typeof(SqlHooks), nameof(Prefix)),
+ finalizer: new HarmonyMethod(typeof(SqlHooks), nameof(Finalizer)));
+ Logger.Debug($"hooked {type.Name}.{name}");
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"cannot hook {type.Name}.{name}: {e.Message}");
+ }
+ }
+ }
+ }
+
+ private sealed class SqlCallContext
+ {
+ public required int CallEventId { get; init; }
+ public required long StartTimestamp { get; init; }
+ }
+
+ [ThreadStatic]
+ private static bool inHook;
+
+ public static void Prefix(MethodBase __originalMethod, object __instance, ref object? __state)
+ {
+ __state = null;
+ if (inHook || !Recorder.Instance.HasActiveSession || __instance is not DbCommand command)
+ return;
+ inHook = true;
+ try
+ {
+ var e = new Event
+ {
+ EventType = "call",
+ DefinedClass = Value.TypeName(__instance.GetType()),
+ MethodId = __originalMethod.Name,
+ Static = false,
+ SqlQuery = new SqlQuery
+ {
+ Sql = command.CommandText,
+ DatabaseType = DatabaseTypeOf(__instance.GetType()),
+ },
+ };
+ Recorder.Instance.Add(e);
+ __state = new SqlCallContext
+ {
+ CallEventId = e.Id,
+ StartTimestamp = Stopwatch.GetTimestamp(),
+ };
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("sql call hook failed", ex);
+ }
+ finally
+ {
+ inHook = false;
+ }
+ }
+
+ public static Exception? Finalizer(Exception? __exception, object? __state)
+ {
+ if (__state is SqlCallContext ctx && !inHook)
+ {
+ inHook = true;
+ try
+ {
+ var e = new Event
+ {
+ EventType = "return",
+ ParentId = ctx.CallEventId,
+ Elapsed = (Stopwatch.GetTimestamp() - ctx.StartTimestamp)
+ / (double)Stopwatch.Frequency,
+ };
+ if (__exception != null)
+ e.Exceptions = ExceptionValue.ChainOf(__exception);
+ Recorder.Instance.Add(e);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error("sql return hook failed", ex);
+ }
+ finally
+ {
+ inHook = false;
+ }
+ }
+ return __exception;
+ }
+
+ ///
+ /// JDBC exposes DatabaseProductName; ADO.NET has no portable equivalent,
+ /// so infer from the provider's type name.
+ ///
+ private static string DatabaseTypeOf(Type commandType)
+ {
+ var name = commandType.FullName ?? commandType.Name;
+ if (name.IndexOf("Npgsql", StringComparison.OrdinalIgnoreCase) >= 0)
+ return "postgres";
+ if (name.IndexOf("Sqlite", StringComparison.OrdinalIgnoreCase) >= 0)
+ return "sqlite";
+ if (name.IndexOf("MySql", StringComparison.OrdinalIgnoreCase) >= 0)
+ return "mysql";
+ if (name.IndexOf("Oracle", StringComparison.OrdinalIgnoreCase) >= 0)
+ return "oracle";
+ if (name.IndexOf("SqlClient", StringComparison.OrdinalIgnoreCase) >= 0)
+ return "mssql";
+ return commandType.Name.ToLowerInvariant();
+ }
+}
diff --git a/managed/src/AppMap.Agent/Output/CodeObjectTree.cs b/managed/src/AppMap.Agent/Output/CodeObjectTree.cs
new file mode 100644
index 0000000..ec4a261
--- /dev/null
+++ b/managed/src/AppMap.Agent/Output/CodeObjectTree.cs
@@ -0,0 +1,81 @@
+namespace AppMap.Output;
+
+///
+/// One node of the class_map: a package (namespace segment), class, or
+/// function. Mirrors com.appland.appmap.output.v1.CodeObject.
+///
+public sealed class CodeObject
+{
+ public required string Name { get; init; }
+
+ /// "package", "class", or "function".
+ public required string Type { get; init; }
+
+ public bool? Static { get; init; }
+
+ /// "path:lineno" for functions, when source info is available.
+ public string? Location { get; init; }
+
+ public List? Labels { get; init; }
+
+ public List Children { get; } = new();
+}
+
+///
+/// Accumulates the class_map for one recording: only code objects whose
+/// functions actually produced events are included, matching the Java
+/// agent's behavior. Thread-safe.
+///
+public sealed class CodeObjectTree
+{
+ private readonly object gate = new();
+ private readonly List roots = new();
+
+ ///
+ /// Registers a function under namespaceName (dotted, possibly empty) and
+ /// a chain of class names (outer-to-inner, for nested types).
+ /// Idempotent per function.
+ ///
+ public void RegisterFunction(string namespaceName, IReadOnlyList classChain,
+ string functionName, bool isStatic, string? location, IReadOnlyList? labels)
+ {
+ lock (gate)
+ {
+ var children = roots;
+ if (namespaceName.Length > 0)
+ {
+ foreach (var part in namespaceName.Split('.'))
+ children = ChildOf(children, part, "package").Children;
+ }
+ foreach (var className in classChain)
+ children = ChildOf(children, className, "class").Children;
+
+ if (children.Any(c => c.Type == "function" && c.Name == functionName
+ && c.Static == isStatic))
+ return;
+ children.Add(new CodeObject
+ {
+ Name = functionName,
+ Type = "function",
+ Static = isStatic,
+ Location = location,
+ Labels = labels is { Count: > 0 } ? labels.ToList() : null,
+ });
+ }
+ }
+
+ public IReadOnlyList Roots
+ {
+ get { lock (gate) { return roots.ToList(); } }
+ }
+
+ private static CodeObject ChildOf(List children, string name, string type)
+ {
+ var existing = children.FirstOrDefault(c => c.Name == name && c.Type == type);
+ if (existing != null)
+ return existing;
+ var node = new CodeObject { Name = name, Type = type };
+ children.Add(node);
+ return node;
+ }
+}
diff --git a/managed/src/AppMap.Agent/Output/Event.cs b/managed/src/AppMap.Agent/Output/Event.cs
new file mode 100644
index 0000000..b1b3d9d
--- /dev/null
+++ b/managed/src/AppMap.Agent/Output/Event.cs
@@ -0,0 +1,109 @@
+namespace AppMap.Output;
+
+///
+/// One entry of the events array: a "call" or "return" event, possibly
+/// decorated with HTTP or SQL details. Field-for-field port of
+/// com.appland.appmap.output.v1.Event; serialization to snake_case JSON
+/// lives in AppMapSerializer.
+///
+public sealed class Event
+{
+ private static int nextId;
+
+ public static int IssueId() => Interlocked.Increment(ref nextId);
+
+ public int Id { get; init; } = IssueId();
+
+ /// "call" or "return".
+ public required string EventType { get; init; }
+
+ public int ThreadId { get; init; } = Environment.CurrentManagedThreadId;
+
+ public string? DefinedClass { get; set; }
+ public string? MethodId { get; set; }
+ public string? Path { get; set; }
+ public int? LineNo { get; set; }
+ public bool? Static { get; set; }
+
+ public Value? Receiver { get; set; }
+ public List? Parameters { get; set; }
+
+ /// On return events: id of the matching call event.
+ public int? ParentId { get; set; }
+
+ public Value? ReturnValue { get; set; }
+ public List? Exceptions { get; set; }
+
+ /// Seconds elapsed between call and return.
+ public double? Elapsed { get; set; }
+
+ public HttpServerRequest? HttpServerRequest { get; set; }
+ public HttpServerResponse? HttpServerResponse { get; set; }
+ public SqlQuery? SqlQuery { get; set; }
+
+ /// HTTP request parameters (query/form), on the request call event.
+ public List? Message { get; set; }
+}
+
+/// An exception attached to a return event, including its cause chain.
+public sealed class ExceptionValue
+{
+ public required string Class { get; init; }
+ public string? Message { get; init; }
+ public string? Path { get; init; }
+ public int? LineNo { get; init; }
+ public long ObjectId { get; init; }
+
+ /// Flattens an exception and its InnerException chain.
+ public static List ChainOf(Exception exception)
+ {
+ var chain = new List();
+ for (Exception? e = exception; e != null; e = e.InnerException)
+ {
+ string? path = null;
+ int? lineno = null;
+ try
+ {
+ var frame = new System.Diagnostics.StackTrace(e, fNeedFileInfo: true).GetFrame(0);
+ path = frame?.GetFileName();
+ var line = frame?.GetFileLineNumber() ?? 0;
+ if (line > 0)
+ lineno = line;
+ }
+ catch
+ {
+ // Source info is best-effort.
+ }
+ chain.Add(new ExceptionValue
+ {
+ Class = Value.TypeName(e.GetType()),
+ Message = e.Message,
+ Path = path,
+ LineNo = lineno,
+ ObjectId = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(e),
+ });
+ }
+ return chain;
+ }
+}
+
+public sealed class HttpServerRequest
+{
+ public required string RequestMethod { get; init; }
+ public required string PathInfo { get; init; }
+ public string? NormalizedPathInfo { get; set; }
+ public string? Protocol { get; init; }
+ public Dictionary? Headers { get; init; }
+}
+
+public sealed class HttpServerResponse
+{
+ public required int Status { get; init; }
+ public Dictionary? Headers { get; init; }
+}
+
+public sealed class SqlQuery
+{
+ public required string Sql { get; init; }
+ public string? DatabaseType { get; init; }
+}
diff --git a/managed/src/AppMap.Agent/Output/Metadata.cs b/managed/src/AppMap.Agent/Output/Metadata.cs
new file mode 100644
index 0000000..32f67b8
--- /dev/null
+++ b/managed/src/AppMap.Agent/Output/Metadata.cs
@@ -0,0 +1,49 @@
+namespace AppMap.Output;
+
+///
+/// The metadata section of an AppMap document. Static fields (language,
+/// client, git) are filled in by AppMapSerializer; this class carries the
+/// per-recording fields. Mirrors com.appland.appmap.record.Metadata.
+///
+public sealed class Metadata
+{
+ /// Scenario name, e.g. "GET /users (200) - 2026-06-10T12:00:00".
+ public string? Name { get; set; }
+
+ /// Application name, from appmap.yml.
+ public string? App { get; set; }
+
+ /// e.g. "xunit", "remote_recording", "request_recording", "process_recording".
+ public required string RecorderName { get; init; }
+
+ /// e.g. "tests", "remote", "requests", "process".
+ public required string RecorderType { get; init; }
+
+ /// recording.defined_class — the test class, when recording a test.
+ public string? RecordingDefinedClass { get; set; }
+
+ /// recording.method_id — the test method, when recording a test.
+ public string? RecordingMethodId { get; set; }
+
+ /// "file:lineno" of the recorded method, when known.
+ public string? SourceLocation { get; set; }
+
+ public List Frameworks { get; } = new();
+
+ /// "succeeded" or "failed", for test recordings.
+ public string? TestStatus { get; set; }
+
+ public TestFailure? TestFailure { get; set; }
+}
+
+public sealed class Framework
+{
+ public required string Name { get; init; }
+ public string? Version { get; init; }
+}
+
+public sealed class TestFailure
+{
+ public required string Message { get; init; }
+ public string? Location { get; init; }
+}
diff --git a/managed/src/AppMap.Agent/Output/Value.cs b/managed/src/AppMap.Agent/Output/Value.cs
new file mode 100644
index 0000000..765e8b7
--- /dev/null
+++ b/managed/src/AppMap.Agent/Output/Value.cs
@@ -0,0 +1,61 @@
+using System.Runtime.CompilerServices;
+using AppMap.Config;
+
+namespace AppMap.Output;
+
+///
+/// A captured parameter, receiver, return value, or message entry —
+/// the "value object" of the AppMap format (name, class, value, object_id,
+/// kind). Mirrors com.appland.appmap.output.v1.Value.
+///
+public sealed class Value
+{
+ public string? Name { get; set; }
+ public string? Kind { get; set; }
+ public string? Class { get; set; }
+ public string? StringValue { get; set; }
+ public long? ObjectId { get; set; }
+
+ public static Value Capture(object? obj, string? name = null,
+ Type? declaredType = null, string? kind = null)
+ {
+ var type = obj?.GetType() ?? declaredType;
+ return new Value
+ {
+ Name = name,
+ Kind = kind,
+ Class = type != null ? TypeName(type) : "object",
+ StringValue = Format(obj),
+ ObjectId = obj == null || obj.GetType().IsValueType
+ ? null
+ : RuntimeHelpers.GetHashCode(obj),
+ };
+ }
+
+ public static string TypeName(Type type) =>
+ (type.FullName ?? type.Name).Replace('+', '.');
+
+ private static string Format(object? obj)
+ {
+ if (obj == null)
+ return "null";
+ if (Properties.DisableValue)
+ return "< disabled >";
+
+ string text;
+ try
+ {
+ text = obj.ToString() ?? "null";
+ }
+ catch
+ {
+ // A throwing ToString() must never take the recording down.
+ return "< invalid >";
+ }
+
+ var max = Properties.MaxValueSize;
+ if (max > 0 && text.Length > max)
+ text = text.Substring(0, max - 3) + "...";
+ return text;
+ }
+}
diff --git a/managed/src/AppMap.Agent/Polyfills.cs b/managed/src/AppMap.Agent/Polyfills.cs
new file mode 100644
index 0000000..d523680
--- /dev/null
+++ b/managed/src/AppMap.Agent/Polyfills.cs
@@ -0,0 +1,28 @@
+// Compiler-recognized attributes that ship in net5.0+/net7.0+ reference
+// assemblies but are absent from netstandard2.0. Declaring them internally
+// lets the same C# (init accessors, required members) compile for the
+// netstandard2.0 target; they have no runtime behavior of their own.
+#if NETSTANDARD2_0
+namespace System.Runtime.CompilerServices
+{
+ internal static class IsExternalInit { }
+
+ [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct
+ | AttributeTargets.Field | AttributeTargets.Property)]
+ internal sealed class RequiredMemberAttribute : Attribute { }
+
+ [AttributeUsage(AttributeTargets.All, AllowMultiple = true)]
+ internal sealed class CompilerFeatureRequiredAttribute : Attribute
+ {
+ public CompilerFeatureRequiredAttribute(string featureName) => FeatureName = featureName;
+
+ public string FeatureName { get; }
+ }
+}
+
+namespace System.Diagnostics.CodeAnalysis
+{
+ [AttributeUsage(AttributeTargets.Constructor)]
+ internal sealed class SetsRequiredMembersAttribute : Attribute { }
+}
+#endif
diff --git a/managed/src/AppMap.Agent/Record/AppMapSerializer.cs b/managed/src/AppMap.Agent/Record/AppMapSerializer.cs
new file mode 100644
index 0000000..ddd8a9b
--- /dev/null
+++ b/managed/src/AppMap.Agent/Record/AppMapSerializer.cs
@@ -0,0 +1,321 @@
+using System.Text;
+using System.Text.Json;
+using AppMap.Output;
+using AppMap.Util;
+
+namespace AppMap.Record;
+
+///
+/// Writes AppMap JSON (format version 1.2, matching appmap-java's
+/// AppMapSerializer). Events are serialized one at a time as standalone
+/// fragments — the recorder streams them to a temp file as they happen, as
+/// the Java agent does — and the final document is assembled around the raw
+/// fragment bytes. Events mutated after being streamed (e.g. the HTTP route
+/// template, known only after routing) are emitted in the spec's
+/// "eventUpdates" section.
+///
+public static class AppMapSerializer
+{
+ public const string FormatVersion = "1.2";
+ public const string ClientName = "appmap-dotnet";
+ public const string ClientUrl = "https://github.com/getappmap/appmap-dotnet";
+
+ /// Serializes one event as a standalone JSON object.
+ public static void WriteEventFragment(Stream stream, Event e)
+ {
+ using var json = new Utf8JsonWriter(stream);
+ WriteEvent(json, e);
+ }
+
+ ///
+ /// Assembles a complete document. writeEvents must emit zero or more
+ /// comma-separated event fragments (raw bytes) — the body of the events
+ /// array.
+ ///
+ public static void WriteDocument(Stream stream, Metadata metadata,
+ CodeObjectTree classMap, Action writeEvents,
+ IReadOnlyDictionary? eventUpdates = null)
+ {
+ WriteRaw(stream, $"{{\"version\":\"{FormatVersion}\",\"metadata\":");
+ using (var json = new Utf8JsonWriter(stream))
+ WriteMetadata(json, metadata);
+
+ // The top-level key is camelCase in the AppMap spec ("classMap"),
+ // unlike the snake_case event fields; the AppMap CLI / VS Code
+ // extension key off this exact name to build the code-object tree.
+ WriteRaw(stream, ",\"classMap\":");
+ using (var json = new Utf8JsonWriter(stream))
+ {
+ json.WriteStartArray();
+ foreach (var root in classMap.Roots)
+ WriteCodeObject(json, root);
+ json.WriteEndArray();
+ }
+
+ WriteRaw(stream, ",\"events\":[");
+ writeEvents(stream);
+ WriteRaw(stream, "]");
+
+ if (eventUpdates is { Count: > 0 })
+ {
+ WriteRaw(stream, ",\"eventUpdates\":{");
+ var first = true;
+ foreach (var update in eventUpdates)
+ {
+ WriteRaw(stream, first ? $"\"{update.Key}\":" : $",\"{update.Key}\":");
+ WriteEventFragment(stream, update.Value);
+ first = false;
+ }
+ WriteRaw(stream, "}");
+ }
+
+ WriteRaw(stream, "}");
+ }
+
+ /// Convenience for buffered event lists (tests, simple callers).
+ public static void Write(Stream stream, Metadata metadata,
+ IReadOnlyList events, CodeObjectTree classMap)
+ {
+ WriteDocument(stream, metadata, classMap, s =>
+ {
+ for (var i = 0; i < events.Count; i++)
+ {
+ if (i > 0)
+ WriteRaw(s, ",");
+ WriteEventFragment(s, events[i]);
+ }
+ });
+ }
+
+ private static void WriteRaw(Stream stream, string text)
+ {
+ var bytes = Encoding.UTF8.GetBytes(text);
+ stream.Write(bytes, 0, bytes.Length);
+ }
+
+ private static void WriteMetadata(Utf8JsonWriter json, Metadata md)
+ {
+ json.WriteStartObject();
+ if (md.Name != null)
+ json.WriteString("name", md.Name);
+ if (md.App != null)
+ json.WriteString("app", md.App);
+
+ json.WriteStartObject("language");
+ json.WriteString("name", "csharp");
+ json.WriteString("version", Environment.Version.ToString());
+ json.WriteString("engine", System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription);
+ json.WriteEndObject();
+
+ json.WriteStartObject("client");
+ json.WriteString("name", ClientName);
+ json.WriteString("url", ClientUrl);
+ json.WriteEndObject();
+
+ json.WriteStartObject("recorder");
+ json.WriteString("name", md.RecorderName);
+ json.WriteString("type", md.RecorderType);
+ json.WriteEndObject();
+
+ if (md.RecordingDefinedClass != null || md.RecordingMethodId != null)
+ {
+ json.WriteStartObject("recording");
+ if (md.RecordingDefinedClass != null)
+ json.WriteString("defined_class", md.RecordingDefinedClass);
+ if (md.RecordingMethodId != null)
+ json.WriteString("method_id", md.RecordingMethodId);
+ json.WriteEndObject();
+ }
+
+ if (md.SourceLocation != null)
+ json.WriteString("source_location", md.SourceLocation);
+
+ if (md.Frameworks.Count > 0)
+ {
+ json.WriteStartArray("frameworks");
+ foreach (var fw in md.Frameworks)
+ {
+ json.WriteStartObject();
+ json.WriteString("name", fw.Name);
+ if (fw.Version != null)
+ json.WriteString("version", fw.Version);
+ json.WriteEndObject();
+ }
+ json.WriteEndArray();
+ }
+
+ if (md.TestStatus != null)
+ json.WriteString("test_status", md.TestStatus);
+ if (md.TestFailure != null)
+ {
+ json.WriteStartObject("test_failure");
+ json.WriteString("message", md.TestFailure.Message);
+ if (md.TestFailure.Location != null)
+ json.WriteString("location", md.TestFailure.Location);
+ json.WriteEndObject();
+ }
+
+ var git = GitMetadata.Collect();
+ if (git != null)
+ {
+ json.WriteStartObject("git");
+ if (git.Repository != null)
+ json.WriteString("repository", git.Repository);
+ if (git.Branch != null)
+ json.WriteString("branch", git.Branch);
+ if (git.Commit != null)
+ json.WriteString("commit", git.Commit);
+ json.WriteEndObject();
+ }
+
+ json.WriteEndObject();
+ }
+
+ private static void WriteCodeObject(Utf8JsonWriter json, CodeObject co)
+ {
+ json.WriteStartObject();
+ json.WriteString("name", co.Name);
+ json.WriteString("type", co.Type);
+ if (co.Static.HasValue)
+ json.WriteBoolean("static", co.Static.Value);
+ if (co.Location != null)
+ json.WriteString("location", co.Location);
+ if (co.Labels is { Count: > 0 })
+ {
+ json.WriteStartArray("labels");
+ foreach (var label in co.Labels)
+ json.WriteStringValue(label);
+ json.WriteEndArray();
+ }
+ if (co.Children.Count > 0)
+ {
+ json.WriteStartArray("children");
+ foreach (var child in co.Children)
+ WriteCodeObject(json, child);
+ json.WriteEndArray();
+ }
+ json.WriteEndObject();
+ }
+
+ private static void WriteEvent(Utf8JsonWriter json, Event e)
+ {
+ json.WriteStartObject();
+ json.WriteNumber("id", e.Id);
+ json.WriteString("event", e.EventType);
+ json.WriteNumber("thread_id", e.ThreadId);
+
+ if (e.DefinedClass != null)
+ json.WriteString("defined_class", e.DefinedClass);
+ if (e.MethodId != null)
+ json.WriteString("method_id", e.MethodId);
+ if (e.Path != null)
+ json.WriteString("path", e.Path);
+ if (e.LineNo.HasValue)
+ json.WriteNumber("lineno", e.LineNo.Value);
+ if (e.Static.HasValue)
+ json.WriteBoolean("static", e.Static.Value);
+
+ if (e.Receiver != null)
+ {
+ json.WritePropertyName("receiver");
+ WriteValue(json, e.Receiver);
+ }
+ if (e.Parameters != null)
+ {
+ json.WriteStartArray("parameters");
+ foreach (var p in e.Parameters)
+ WriteValue(json, p);
+ json.WriteEndArray();
+ }
+ if (e.Message != null)
+ {
+ json.WriteStartArray("message");
+ foreach (var p in e.Message)
+ WriteValue(json, p);
+ json.WriteEndArray();
+ }
+
+ if (e.ParentId.HasValue)
+ json.WriteNumber("parent_id", e.ParentId.Value);
+ if (e.Elapsed.HasValue)
+ json.WriteNumber("elapsed", e.Elapsed.Value);
+ if (e.ReturnValue != null)
+ {
+ json.WritePropertyName("return_value");
+ WriteValue(json, e.ReturnValue);
+ }
+ if (e.Exceptions is { Count: > 0 })
+ {
+ json.WriteStartArray("exceptions");
+ foreach (var ex in e.Exceptions)
+ {
+ json.WriteStartObject();
+ json.WriteString("class", ex.Class);
+ if (ex.Message != null)
+ json.WriteString("message", ex.Message);
+ if (ex.Path != null)
+ json.WriteString("path", ex.Path);
+ if (ex.LineNo.HasValue)
+ json.WriteNumber("lineno", ex.LineNo.Value);
+ json.WriteNumber("object_id", ex.ObjectId);
+ json.WriteEndObject();
+ }
+ json.WriteEndArray();
+ }
+
+ if (e.HttpServerRequest is { } req)
+ {
+ json.WriteStartObject("http_server_request");
+ json.WriteString("request_method", req.RequestMethod);
+ json.WriteString("path_info", req.PathInfo);
+ if (req.NormalizedPathInfo != null)
+ json.WriteString("normalized_path_info", req.NormalizedPathInfo);
+ if (req.Protocol != null)
+ json.WriteString("protocol", req.Protocol);
+ WriteHeaders(json, req.Headers);
+ json.WriteEndObject();
+ }
+ if (e.HttpServerResponse is { } res)
+ {
+ json.WriteStartObject("http_server_response");
+ json.WriteNumber("status", res.Status);
+ WriteHeaders(json, res.Headers);
+ json.WriteEndObject();
+ }
+ if (e.SqlQuery is { } sql)
+ {
+ json.WriteStartObject("sql_query");
+ json.WriteString("sql", sql.Sql);
+ if (sql.DatabaseType != null)
+ json.WriteString("database_type", sql.DatabaseType);
+ json.WriteEndObject();
+ }
+
+ json.WriteEndObject();
+ }
+
+ private static void WriteHeaders(Utf8JsonWriter json, Dictionary? headers)
+ {
+ if (headers is not { Count: > 0 })
+ return;
+ json.WriteStartObject("headers");
+ foreach (var header in headers)
+ json.WriteString(header.Key, header.Value);
+ json.WriteEndObject();
+ }
+
+ private static void WriteValue(Utf8JsonWriter json, Value v)
+ {
+ json.WriteStartObject();
+ if (v.Name != null)
+ json.WriteString("name", v.Name);
+ if (v.Kind != null)
+ json.WriteString("kind", v.Kind);
+ if (v.Class != null)
+ json.WriteString("class", v.Class);
+ json.WriteString("value", v.StringValue ?? "null");
+ if (v.ObjectId.HasValue)
+ json.WriteNumber("object_id", v.ObjectId.Value);
+ json.WriteEndObject();
+ }
+}
diff --git a/managed/src/AppMap.Agent/Record/Recorder.cs b/managed/src/AppMap.Agent/Record/Recorder.cs
new file mode 100644
index 0000000..36c68a3
--- /dev/null
+++ b/managed/src/AppMap.Agent/Record/Recorder.cs
@@ -0,0 +1,173 @@
+using AppMap.Output;
+using AppMap.Util;
+
+namespace AppMap.Record;
+
+///
+/// The process-wide recorder, mirroring com.appland.appmap.record.Recorder.
+/// There is one optional global session (remote/process/test recording) plus
+/// an async-local session for per-request recording — AsyncLocal rather than
+/// the Java agent's ThreadLocal so a recording follows its request across
+/// awaits.
+///
+public sealed class Recorder
+{
+ public static Recorder Instance { get; } = new();
+
+ private readonly object gate = new();
+ private volatile RecordingSession? globalSession;
+ private readonly AsyncLocal localSession = new();
+
+ private Recorder() { }
+
+ public bool HasActiveSession => globalSession != null || localSession.Value != null;
+
+ public bool HasGlobalSession => globalSession != null;
+
+ /// Starts the global session. Throws if one is already active.
+ public void Start(Metadata metadata)
+ {
+ lock (gate)
+ {
+ if (globalSession != null)
+ throw new InvalidOperationException("a recording session is already in progress");
+ globalSession = new RecordingSession(metadata);
+ Logger.Debug($"started global recording ({metadata.RecorderName})");
+ }
+ }
+
+ /// Stops the global session and returns the recording, or null.
+ public Recording? Stop()
+ {
+ lock (gate)
+ {
+ var session = globalSession;
+ globalSession = null;
+ if (session == null)
+ return null;
+ Logger.Debug("stopped global recording");
+ return session.Finish();
+ }
+ }
+
+ /// Snapshots the global session without stopping it (remote checkpoint).
+ public Recording? Checkpoint() => globalSession?.Snapshot();
+
+ /// Starts a session bound to the current async flow (request recording).
+ public void StartLocal(Metadata metadata) =>
+ localSession.Value = new RecordingSession(metadata);
+
+ public Recording? StopLocal()
+ {
+ var session = localSession.Value;
+ localSession.Value = null;
+ return session?.Finish();
+ }
+
+ /// Routes an event to the active session(s), registering its code object.
+ public void Add(Event e, Action? registerCodeObject = null)
+ {
+ var local = localSession.Value;
+ local?.Add(e, registerCodeObject);
+ // Both can be active at once (e.g. remote recording while request
+ // recording is on); the Java agent does the same.
+ globalSession?.Add(e, registerCodeObject);
+ }
+
+ ///
+ /// Re-records an already-added event that was mutated afterwards (e.g.
+ /// normalized_path_info, known only after routing). Streamed sessions
+ /// emit it in the document's eventUpdates section.
+ ///
+ public void Update(Event e)
+ {
+ localSession.Value?.Update(e);
+ globalSession?.Update(e);
+ }
+}
+
+///
+/// An in-progress recording. Events are serialized to a temp file as they
+/// arrive — like appmap-java's streaming RecordingSession — so memory use
+/// does not grow with recording length; the class map and any post-hoc
+/// event updates stay in memory (both are small).
+///
+public sealed class RecordingSession
+{
+ private readonly object gate = new();
+ private readonly CodeObjectTree classMap = new();
+ private readonly Dictionary updates = new();
+ private string? eventsPath;
+ private FileStream? eventsStream;
+ private int eventCount;
+ private bool finished;
+
+ public Metadata Metadata { get; }
+
+ public RecordingSession(Metadata metadata) => Metadata = metadata;
+
+ public void Add(Event e, Action? registerCodeObject)
+ {
+ lock (gate)
+ {
+ // A deferred async return may arrive after the session was
+ // finished (fire-and-forget Task completing post-request); drop
+ // it rather than reopening the closed event stream.
+ if (finished)
+ return;
+ if (eventsStream == null)
+ {
+ eventsPath = Path.Combine(Path.GetTempPath(),
+ $"appmap-{Guid.NewGuid():N}.events.json");
+ eventsStream = new FileStream(eventsPath, FileMode.CreateNew,
+ FileAccess.Write, FileShare.Read);
+ }
+ if (eventCount > 0)
+ eventsStream.WriteByte((byte)',');
+ AppMapSerializer.WriteEventFragment(eventsStream, e);
+ eventCount++;
+ }
+ registerCodeObject?.Invoke(classMap);
+ }
+
+ public void Update(Event e)
+ {
+ lock (gate)
+ {
+ if (!finished && eventCount > 0)
+ updates[e.Id] = e;
+ }
+ }
+
+ /// Closes the event stream and hands the temp file to the Recording.
+ public Recording Finish()
+ {
+ lock (gate)
+ {
+ finished = true;
+ eventsStream?.Dispose();
+ eventsStream = null;
+ return new Recording(Metadata, classMap, eventsPath, eventCount,
+ new Dictionary(updates));
+ }
+ }
+
+ /// Copies the events so far into a new Recording, leaving the
+ /// session running (remote checkpoint).
+ public Recording Snapshot()
+ {
+ lock (gate)
+ {
+ string? snapshotPath = null;
+ if (eventsPath != null)
+ {
+ eventsStream?.Flush();
+ snapshotPath = Path.Combine(Path.GetTempPath(),
+ $"appmap-{Guid.NewGuid():N}.events.json");
+ File.Copy(eventsPath, snapshotPath);
+ }
+ return new Recording(Metadata, classMap, snapshotPath, eventCount,
+ new Dictionary(updates));
+ }
+ }
+}
diff --git a/managed/src/AppMap.Agent/Record/Recording.cs b/managed/src/AppMap.Agent/Record/Recording.cs
new file mode 100644
index 0000000..9f13be2
--- /dev/null
+++ b/managed/src/AppMap.Agent/Record/Recording.cs
@@ -0,0 +1,107 @@
+using System.Security.Cryptography;
+using System.Text;
+using AppMap.Config;
+using AppMap.Output;
+using AppMap.Util;
+
+namespace AppMap.Record;
+
+///
+/// A finished recording, ready to be serialized: metadata and class map in
+/// memory, events as pre-serialized fragments in a temp file — mirroring
+/// com.appland.appmap.record.Recording, which likewise hands off a streamed
+/// temp file. Save/Discard remove the temp file.
+///
+public sealed class Recording
+{
+ private const int FileNameMaxLength = 255;
+ public const string AppMapSuffix = ".appmap.json";
+
+ private readonly string? eventsPath;
+ private readonly IReadOnlyDictionary eventUpdates;
+
+ public Metadata Metadata { get; }
+ public CodeObjectTree ClassMap { get; }
+ public int EventCount { get; }
+
+ public Recording(Metadata metadata, CodeObjectTree classMap,
+ string? eventsPath, int eventCount, IReadOnlyDictionary eventUpdates)
+ {
+ Metadata = metadata;
+ ClassMap = classMap;
+ this.eventsPath = eventsPath;
+ EventCount = eventCount;
+ this.eventUpdates = eventUpdates;
+ Metadata.App ??= AppMapConfig.Current.Name;
+ }
+
+ public void WriteTo(Stream stream) =>
+ AppMapSerializer.WriteDocument(stream, Metadata, ClassMap, CopyEvents, eventUpdates);
+
+ private void CopyEvents(Stream stream)
+ {
+ if (eventsPath == null || !File.Exists(eventsPath))
+ return;
+ using var events = new FileStream(eventsPath, FileMode.Open,
+ FileAccess.Read, FileShare.ReadWrite);
+ events.CopyTo(stream);
+ }
+
+ public string ToJson()
+ {
+ using var buffer = new MemoryStream();
+ WriteTo(buffer);
+ return Encoding.UTF8.GetString(buffer.ToArray());
+ }
+
+ ///
+ /// Writes to {appmap_dir}/{recorder_name}/{name}.appmap.json, sanitizing
+ /// and hashing over-long names like the Java agent. Returns the path.
+ ///
+ public string Save(string? baseName = null)
+ {
+ var dir = Path.Combine(AppMapConfig.Current.OutputDirectory, Metadata.RecorderName);
+ Directory.CreateDirectory(dir);
+ var fileName = SanitizeFileName(baseName ?? Metadata.Name ?? $"recording_{DateTime.Now:yyyyMMddHHmmssfff}");
+ var path = Path.Combine(dir, fileName + AppMapSuffix);
+ using (var stream = File.Create(path))
+ WriteTo(stream);
+ Discard();
+ Logger.Debug($"wrote {EventCount} events to {path}");
+ return path;
+ }
+
+ /// Deletes the temp events file (also called by Save).
+ public void Discard()
+ {
+ try
+ {
+ if (eventsPath != null && File.Exists(eventsPath))
+ File.Delete(eventsPath);
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"could not remove {eventsPath}: {e.Message}");
+ }
+ }
+
+ private static string SanitizeFileName(string name)
+ {
+ var sb = new StringBuilder(name.Length);
+ foreach (var c in name)
+ sb.Append(char.IsLetterOrDigit(c) || c is '-' or '.' ? c : '_');
+ var sanitized = sb.ToString();
+
+ var budget = FileNameMaxLength - AppMapSuffix.Length;
+ if (sanitized.Length <= budget)
+ return sanitized;
+
+ // Keep the name unique after truncation by appending a short hash,
+ // as the Java agent does.
+ using var sha = SHA256.Create();
+ var digest = sha.ComputeHash(Encoding.UTF8.GetBytes(sanitized));
+ var hash = BitConverter.ToString(digest, 0, 4).Replace("-", "")
+ .Substring(0, 7).ToLowerInvariant();
+ return sanitized.Substring(0, budget - hash.Length - 1) + "-" + hash;
+ }
+}
diff --git a/managed/src/AppMap.Agent/Util/GitMetadata.cs b/managed/src/AppMap.Agent/Util/GitMetadata.cs
new file mode 100644
index 0000000..af7a687
--- /dev/null
+++ b/managed/src/AppMap.Agent/Util/GitMetadata.cs
@@ -0,0 +1,129 @@
+using AppMap.Config;
+
+namespace AppMap.Util;
+
+///
+/// Collects git repository/branch/commit for the metadata section by reading
+/// .git directly (the Java agent uses JGit; we avoid the dependency).
+///
+public sealed class GitMetadata
+{
+ public string? Repository { get; private init; }
+ public string? Branch { get; private init; }
+ public string? Commit { get; private init; }
+
+ private static GitMetadata? cached;
+ private static bool resolved;
+
+ public static GitMetadata? Collect()
+ {
+ if (resolved)
+ return cached;
+ resolved = true;
+ if (Properties.DisableGit)
+ return null;
+ try
+ {
+ cached = Read();
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"git metadata unavailable: {e.Message}");
+ }
+ return cached;
+ }
+
+ private static GitMetadata? Read()
+ {
+ var gitDir = FindGitDir();
+ if (gitDir == null)
+ return null;
+
+ string? branch = null, commit = null;
+ var head = File.ReadAllText(Path.Combine(gitDir, "HEAD")).Trim();
+ if (head.StartsWith("ref: ", StringComparison.Ordinal))
+ {
+ var refName = head.Substring(5);
+ branch = refName.StartsWith("refs/heads/", StringComparison.Ordinal)
+ ? refName.Substring(11) : refName;
+ commit = ResolveRef(gitDir, refName);
+ }
+ else
+ {
+ commit = head; // detached HEAD
+ }
+
+ return new GitMetadata
+ {
+ Repository = ReadOriginUrl(gitDir),
+ Branch = branch,
+ Commit = commit,
+ };
+ }
+
+ ///
+ /// The working-tree root (the directory containing .git), or null
+ /// when not in a git checkout. Used to relativize source paths so maps
+ /// recorded on one machine/OS resolve on another.
+ ///
+ public static string? RepositoryRoot
+ {
+ get
+ {
+ var gitDir = FindGitDir();
+ return gitDir == null ? null : Path.GetDirectoryName(gitDir);
+ }
+ }
+
+ private static string? FindGitDir()
+ {
+ var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
+ while (dir != null)
+ {
+ var candidate = Path.Combine(dir.FullName, ".git");
+ if (Directory.Exists(candidate))
+ return candidate;
+ dir = dir.Parent;
+ }
+ return null;
+ }
+
+ private static string? ResolveRef(string gitDir, string refName)
+ {
+ var refFile = Path.Combine(gitDir, refName);
+ if (File.Exists(refFile))
+ return File.ReadAllText(refFile).Trim();
+
+ var packedRefs = Path.Combine(gitDir, "packed-refs");
+ if (File.Exists(packedRefs))
+ {
+ foreach (var line in File.ReadLines(packedRefs))
+ {
+ if (line.EndsWith(" " + refName, StringComparison.Ordinal))
+ return line.Split(' ')[0];
+ }
+ }
+ return null;
+ }
+
+ private static string? ReadOriginUrl(string gitDir)
+ {
+ var configFile = Path.Combine(gitDir, "config");
+ if (!File.Exists(configFile))
+ return null;
+ var inOrigin = false;
+ foreach (var raw in File.ReadLines(configFile))
+ {
+ var line = raw.Trim();
+ if (line.StartsWith("[", StringComparison.Ordinal))
+ inOrigin = line.Replace(" ", "") == "[remote\"origin\"]";
+ else if (inOrigin && line.StartsWith("url", StringComparison.Ordinal))
+ {
+ var eq = line.IndexOf('=');
+ if (eq > 0)
+ return line.Substring(eq + 1).Trim();
+ }
+ }
+ return null;
+ }
+}
diff --git a/managed/src/AppMap.Agent/Util/Logger.cs b/managed/src/AppMap.Agent/Util/Logger.cs
new file mode 100644
index 0000000..9c9620b
--- /dev/null
+++ b/managed/src/AppMap.Agent/Util/Logger.cs
@@ -0,0 +1,33 @@
+using AppMap.Config;
+
+namespace AppMap.Util;
+
+///
+/// Minimal stderr logger. The agent must never write to stdout (the host
+/// application owns it) and must never throw from a logging call.
+///
+public static class Logger
+{
+ public static void Debug(string message)
+ {
+ if (Properties.Debug)
+ Write("debug", message);
+ }
+
+ public static void Warn(string message) => Write("warn", message);
+
+ public static void Error(string message, Exception? e = null) =>
+ Write("error", e == null ? message : $"{message}: {e}");
+
+ private static void Write(string level, string message)
+ {
+ try
+ {
+ Console.Error.WriteLine($"[appmap {level}] {message}");
+ }
+ catch
+ {
+ // Nothing sensible to do.
+ }
+ }
+}
diff --git a/managed/src/AppMap.Agent/Util/SourceLocator.cs b/managed/src/AppMap.Agent/Util/SourceLocator.cs
new file mode 100644
index 0000000..5f012e7
--- /dev/null
+++ b/managed/src/AppMap.Agent/Util/SourceLocator.cs
@@ -0,0 +1,161 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using System.Reflection.Metadata;
+using System.Reflection.Metadata.Ecma335;
+using AppMap.Config;
+
+namespace AppMap.Util;
+
+///
+/// Resolves a method's source file and line from its PDB, the .NET analog of
+/// the Java agent reading the LineNumberTable from bytecode. Portable PDBs
+/// are read directly; classic Windows PDBs fall back to the native
+/// diasymreader binder (Windows only). Best-effort: returns nulls when no
+/// usable PDB sits next to the assembly.
+///
+/// Paths are emitted relative to the project root (the appmap.yml directory,
+/// then the git root) with forward slashes — like appmap-java — so a map
+/// recorded on Windows (C:\src\repo\...) resolves against the same
+/// repo checked out on Linux. PDBs embed the absolute build-machine path, so
+/// without this, cross-platform queries (record on Windows, analyze on Linux)
+/// break.
+///
+public static class SourceLocator
+{
+ private static readonly Lazy Roots = new(ResolveRoots);
+
+ private static string[] ResolveRoots()
+ {
+ var roots = new List();
+ // Repo root (git) first — "relative to the repo root" is what the CLI
+ // and IDE resolve against. The appmap.yml directory is a fallback for
+ // apps run outside a git checkout (e.g. a published deployment).
+ var gitRoot = GitMetadata.RepositoryRoot;
+ if (!string.IsNullOrEmpty(gitRoot))
+ roots.Add(gitRoot!);
+ var baseDir = AppMapConfig.Current.BaseDirectory;
+ if (!string.IsNullOrEmpty(baseDir) && !roots.Contains(baseDir))
+ roots.Add(baseDir);
+ return roots.ToArray();
+ }
+
+ ///
+ /// Makes an absolute PDB document path relative to the project/git root
+ /// and normalizes separators to '/'. Out-of-tree paths (e.g. third-party
+ /// sources) keep their location but still get forward slashes. Pure;
+ /// exposed for tests.
+ ///
+ internal static string RelativizeAgainst(string path, IEnumerable roots)
+ {
+ var normalized = path.Replace('\\', '/');
+ foreach (var root in roots)
+ {
+ if (string.IsNullOrEmpty(root))
+ continue;
+ var r = root.Replace('\\', '/').TrimEnd('/');
+ if (r.Length > 0 &&
+ normalized.StartsWith(r + "/", StringComparison.OrdinalIgnoreCase))
+ return normalized.Substring(r.Length + 1);
+ }
+ return normalized;
+ }
+
+ private abstract class PdbSource
+ {
+ public abstract (string? Path, int? LineNo) Locate(MethodBase method);
+ }
+
+ private static readonly ConcurrentDictionary sources = new();
+
+ public static (string? Path, int? LineNo) Locate(MethodBase method)
+ {
+ try
+ {
+ var source = sources.GetOrAdd(method.Module.Assembly, Open);
+ var (path, lineNo) = source?.Locate(method) ?? (null, null);
+ return (path == null ? null : RelativizeAgainst(path, Roots.Value), lineNo);
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"no source info for {method.Name}: {e.Message}");
+ return (null, null);
+ }
+ }
+
+ private static PdbSource? Open(Assembly assembly)
+ {
+ var location = assembly.Location;
+ if (string.IsNullOrEmpty(location))
+ return null;
+ var pdbPath = Path.ChangeExtension(location, ".pdb");
+ if (!File.Exists(pdbPath))
+ return null;
+
+ try
+ {
+ // The provider must outlive the reader; it is intentionally kept
+ // alive for the process lifetime alongside the cached reader.
+ var provider = MetadataReaderProvider.FromPortablePdbStream(
+ File.OpenRead(pdbPath));
+ return new PortableSource(provider.GetMetadataReader());
+ }
+ catch (BadImageFormatException)
+ {
+ // Not a portable PDB; try the classic Windows reader.
+ if (WindowsPdbReader.IsSupported
+ && WindowsPdbReader.Open(assembly) is { } reader)
+ {
+ Logger.Debug($"using Windows PDB for {assembly.GetName().Name}");
+ return new WindowsSource(reader);
+ }
+ Logger.Debug($"{pdbPath} is not a portable PDB; "
+ + "build with portable for source locations");
+ return null;
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"failed to open PDB for {assembly.GetName().Name}: {e.Message}");
+ return null;
+ }
+ }
+
+ private sealed class PortableSource : PdbSource
+ {
+ private readonly MetadataReader reader;
+
+ public PortableSource(MetadataReader reader) => this.reader = reader;
+
+ public override (string? Path, int? LineNo) Locate(MethodBase method)
+ {
+ var handle = MetadataTokens.MethodDebugInformationHandle(method.MetadataToken);
+ var debugInfo = reader.GetMethodDebugInformation(handle);
+ if (debugInfo.SequencePointsBlob.IsNil)
+ return (null, null);
+
+ foreach (var sp in debugInfo.GetSequencePoints())
+ {
+ if (sp.IsHidden)
+ continue;
+ var doc = reader.GetDocument(sp.Document);
+ return (reader.GetString(doc.Name), sp.StartLine);
+ }
+ return (null, null);
+ }
+ }
+
+ private sealed class WindowsSource : PdbSource
+ {
+ private readonly WindowsPdbReader.ISymUnmanagedReader reader;
+ private readonly object gate = new();
+
+ public WindowsSource(WindowsPdbReader.ISymUnmanagedReader reader) =>
+ this.reader = reader;
+
+ public override (string? Path, int? LineNo) Locate(MethodBase method)
+ {
+ // diasymreader readers are not thread-safe.
+ lock (gate)
+ return WindowsPdbReader.Locate(reader, method);
+ }
+ }
+}
diff --git a/managed/src/AppMap.Agent/Util/WindowsPdbReader.cs b/managed/src/AppMap.Agent/Util/WindowsPdbReader.cs
new file mode 100644
index 0000000..9fc1f7a
--- /dev/null
+++ b/managed/src/AppMap.Agent/Util/WindowsPdbReader.cs
@@ -0,0 +1,178 @@
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text;
+
+namespace AppMap.Util;
+
+///
+/// Best-effort sequence-point reader for classic (Windows) PDBs via the
+/// native diasymreader binder — the fallback for .NET Framework builds that
+/// cannot use <DebugType>portable</DebugType>. Windows-only; on any
+/// failure (binder not registered, mismatched PDB, non-Windows OS) callers
+/// fall back to "no source locations", which is the agent's behavior for a
+/// missing PDB.
+///
+internal static class WindowsPdbReader
+{
+ public static bool IsSupported =>
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ /// Opens a reader for the assembly, or null.
+ public static ISymUnmanagedReader? Open(Assembly assembly)
+ {
+ if (!IsSupported)
+ return null;
+ try
+ {
+ var location = assembly.Location;
+ if (string.IsNullOrEmpty(location))
+ return null;
+
+ // IMetaDataDispenser -> IMetaDataImport for the assembly, which
+ // the binder uses to pair methods with PDB entries.
+ var dispenser = (IMetaDataDispenser)Activator.CreateInstance(
+ Type.GetTypeFromCLSID(Clsid.CorMetaDataDispenser, throwOnError: true)!)!;
+ var importIid = Iid.IMetaDataImport;
+ dispenser.OpenScope(location, 0 /* read */, ref importIid, out var import);
+
+ var binder = (ISymUnmanagedBinder)Activator.CreateInstance(
+ Type.GetTypeFromCLSID(Clsid.CorSymBinderSxS, throwOnError: true)!)!;
+ var hr = binder.GetReaderForFile(import, location, null, out var reader);
+ return hr == 0 ? reader : null;
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"Windows PDB unavailable for {assembly.GetName().Name}: {e.Message}");
+ return null;
+ }
+ }
+
+ /// First non-hidden sequence point of the method, or nulls.
+ public static (string? Path, int? LineNo) Locate(ISymUnmanagedReader reader, MethodBase method)
+ {
+ try
+ {
+ if (reader.GetMethod(method.MetadataToken, out var symMethod) != 0)
+ return (null, null);
+ symMethod.GetSequencePointCount(out var count);
+ if (count <= 0)
+ return (null, null);
+
+ var offsets = new int[count];
+ var documents = new ISymUnmanagedDocument[count];
+ var lines = new int[count];
+ var columns = new int[count];
+ var endLines = new int[count];
+ var endColumns = new int[count];
+ symMethod.GetSequencePoints(count, out var actual, offsets, documents,
+ lines, columns, endLines, endColumns);
+
+ for (var i = 0; i < actual; i++)
+ {
+ // 0xFEEFEE marks a hidden sequence point.
+ if (lines[i] is 0 or 0xFEEFEE || documents[i] == null)
+ continue;
+ return (UrlOf(documents[i]), lines[i]);
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.Debug($"no Windows PDB info for {method.Name}: {e.Message}");
+ }
+ return (null, null);
+ }
+
+ private static string? UrlOf(ISymUnmanagedDocument document)
+ {
+ document.GetUrl(0, out var length, null);
+ if (length <= 0)
+ return null;
+ var buffer = new StringBuilder(length);
+ document.GetUrl(length, out _, buffer);
+ return buffer.ToString();
+ }
+
+ private static class Clsid
+ {
+ public static readonly Guid CorMetaDataDispenser =
+ new("E5CB7A31-7512-11D2-89CE-0080C792E5D8");
+
+ public static readonly Guid CorSymBinderSxS =
+ new("0A29FF9E-7F9C-4437-8B11-F424491E3931");
+ }
+
+ private static class Iid
+ {
+ public static readonly Guid IMetaDataImport =
+ new("7DAC8207-D3AE-4C75-9B67-92801A497D44");
+ }
+
+ // Minimal COM declarations. Unused vtable slots are declared as
+ // placeholders so the slot order matches corsym.idl exactly.
+
+ [ComImport, Guid("809C652E-7396-11D2-9771-00A0C9B4D50C"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal interface IMetaDataDispenser
+ {
+ void DefineScope_Placeholder();
+
+ void OpenScope([MarshalAs(UnmanagedType.LPWStr)] string szScope, int dwOpenFlags,
+ ref Guid riid, [MarshalAs(UnmanagedType.IUnknown)] out object punk);
+ }
+
+ [ComImport, Guid("AA544D42-28CB-11D3-BD22-0000F80849BD"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal interface ISymUnmanagedBinder
+ {
+ [PreserveSig]
+ int GetReaderForFile([MarshalAs(UnmanagedType.IUnknown)] object importer,
+ [MarshalAs(UnmanagedType.LPWStr)] string fileName,
+ [MarshalAs(UnmanagedType.LPWStr)] string? searchPath,
+ out ISymUnmanagedReader reader);
+ }
+
+ [ComImport, Guid("B4CE6286-2A6B-3712-A3B7-1EE1DAD467B5"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal interface ISymUnmanagedReader
+ {
+ void GetDocument_Placeholder();
+ void GetDocuments_Placeholder();
+ void GetUserEntryPoint_Placeholder();
+
+ [PreserveSig]
+ int GetMethod(int token, out ISymUnmanagedMethod method);
+ }
+
+ [ComImport, Guid("B62B923C-B500-3158-A543-24F307A8B7E1"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal interface ISymUnmanagedMethod
+ {
+ void GetToken_Placeholder();
+
+ void GetSequencePointCount(out int count);
+
+ void GetRootScope_Placeholder();
+ void GetScopeFromOffset_Placeholder();
+ void GetOffset_Placeholder();
+ void GetRanges_Placeholder();
+ void GetParameters_Placeholder();
+ void GetNamespace_Placeholder();
+ void GetSourceStartEnd_Placeholder();
+
+ void GetSequencePoints(int cPoints, out int pcPoints,
+ [In, Out, MarshalAs(UnmanagedType.LPArray)] int[] offsets,
+ [In, Out, MarshalAs(UnmanagedType.LPArray)] ISymUnmanagedDocument[] documents,
+ [In, Out, MarshalAs(UnmanagedType.LPArray)] int[] lines,
+ [In, Out, MarshalAs(UnmanagedType.LPArray)] int[] columns,
+ [In, Out, MarshalAs(UnmanagedType.LPArray)] int[] endLines,
+ [In, Out, MarshalAs(UnmanagedType.LPArray)] int[] endColumns);
+ }
+
+ [ComImport, Guid("40DE4037-7C81-3E1E-B022-AE1ABFF2CA08"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ internal interface ISymUnmanagedDocument
+ {
+ void GetUrl(int cchUrl, out int pcchUrl,
+ [MarshalAs(UnmanagedType.LPWStr)] StringBuilder? szUrl);
+ }
+}
diff --git a/managed/src/AppMap.AspNetCore/AppMap.AspNetCore.csproj b/managed/src/AppMap.AspNetCore/AppMap.AspNetCore.csproj
new file mode 100644
index 0000000..757436a
--- /dev/null
+++ b/managed/src/AppMap.AspNetCore/AppMap.AspNetCore.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ AppMap.AspNetCore
+ ASP.NET Core integration for the AppMap .NET agent: HTTP event recording, per-request AppMaps, and remote recording endpoints.
+
+
+
+
+
+
+
+
diff --git a/managed/src/AppMap.AspNetCore/AppMapApplicationBuilderExtensions.cs b/managed/src/AppMap.AspNetCore/AppMapApplicationBuilderExtensions.cs
new file mode 100644
index 0000000..9289f0a
--- /dev/null
+++ b/managed/src/AppMap.AspNetCore/AppMapApplicationBuilderExtensions.cs
@@ -0,0 +1,30 @@
+using AppMap.Config;
+using Microsoft.AspNetCore.Builder;
+
+namespace AppMap.AspNetCore;
+
+public static class AppMapApplicationBuilderExtensions
+{
+ ///
+ /// Enables AppMap recording: initializes the agent (instrumentation per
+ /// appmap.yml), serves the /_appmap/record remote-recording endpoints,
+ /// and records HTTP server events. Add it first in the pipeline so the
+ /// recording brackets the whole request:
+ /// app.UseAppMap();
+ ///
+ public static IApplicationBuilder UseAppMap(this IApplicationBuilder app)
+ {
+ // Idempotent: the zero-touch HostingStartup may prepend this while the
+ // app also calls it by hand — register the middleware only once.
+ const string registeredKey = "__AppMap.Registered";
+ if (app.Properties.ContainsKey(registeredKey))
+ return app;
+ app.Properties[registeredKey] = true;
+
+ AgentBootstrap.Init();
+ if (Properties.RecordingRemote)
+ app.UseMiddleware();
+ app.UseMiddleware();
+ return app;
+ }
+}
diff --git a/managed/src/AppMap.AspNetCore/AppMapHostingStartup.cs b/managed/src/AppMap.AspNetCore/AppMapHostingStartup.cs
new file mode 100644
index 0000000..1a3a37e
--- /dev/null
+++ b/managed/src/AppMap.AspNetCore/AppMapHostingStartup.cs
@@ -0,0 +1,48 @@
+using AppMap.AspNetCore;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+
+// Registered automatically by ASP.NET Core when this assembly is named in
+// ASPNETCORE_HOSTINGSTARTUPASSEMBLIES — that is the whole zero-touch hook.
+[assembly: HostingStartup(typeof(AppMapHostingStartup))]
+
+namespace AppMap.AspNetCore;
+
+///
+/// Zero-touch attach. Setting
+/// ASPNETCORE_HOSTINGSTARTUPASSEMBLIES=AppMap.AspNetCore makes ASP.NET
+/// Core load this assembly and run before the
+/// application's own startup — with no change to the app's source. It is the
+/// .NET analog of a Java -javaagent auto-registering its servlet
+/// filter. We register an that prepends
+/// UseAppMap() to the pipeline, so HTTP (and, through the agent's SQL
+/// hooks, sql_query) events are recorded for an unmodified app.
+///
+/// Pair it with DOTNET_STARTUP_HOOKS so method/SQL instrumentation is
+/// installed too; the hook's assembly-resolve handler also makes this
+/// assembly loadable from the agent directory.
+///
+public sealed class AppMapHostingStartup : IHostingStartup
+{
+ public void Configure(IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ services.AddTransient());
+ }
+}
+
+///
+/// Prepends UseAppMap() to the request pipeline. Running before the
+/// app's own configuration brackets the whole request, just like adding
+/// app.UseAppMap() as the first middleware by hand.
+///
+internal sealed class AppMapStartupFilter : IStartupFilter
+{
+ public Action Configure(Action next)
+ => app =>
+ {
+ app.UseAppMap();
+ next(app);
+ };
+}
diff --git a/managed/src/AppMap.AspNetCore/AppMapMiddleware.cs b/managed/src/AppMap.AspNetCore/AppMapMiddleware.cs
new file mode 100644
index 0000000..1f188af
--- /dev/null
+++ b/managed/src/AppMap.AspNetCore/AppMapMiddleware.cs
@@ -0,0 +1,140 @@
+using System.Diagnostics;
+using AppMap.Config;
+using AppMap.Output;
+using AppMap.Record;
+using AppMap.Util;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace AppMap.AspNetCore;
+
+///
+/// Records http_server_request / http_server_response events around the
+/// pipeline, and (when APPMAP_RECORDING_REQUESTS is on and no remote
+/// recording is active) writes one AppMap per request — the analog of
+/// appmap-java's servlet hooks plus RequestRecording.
+///
+public sealed class AppMapMiddleware
+{
+ private readonly RequestDelegate next;
+
+ public AppMapMiddleware(RequestDelegate next) => this.next = next;
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ if (context.Request.Path.StartsWithSegments(RemoteRecordingMiddleware.RecordRoute))
+ {
+ await next(context);
+ return;
+ }
+
+ var requestRecording = Properties.RecordingRequests;
+ if (requestRecording)
+ {
+ Recorder.Instance.StartLocal(new Metadata
+ {
+ RecorderName = "request_recording",
+ RecorderType = "requests",
+ });
+ }
+
+ var callEvent = BuildRequestEvent(context);
+ Recorder.Instance.Add(callEvent);
+ var start = Stopwatch.GetTimestamp();
+
+ try
+ {
+ await next(context);
+ }
+ finally
+ {
+ // The route template is only known after routing has run; the
+ // call event was already streamed out, so re-record it through
+ // the document's eventUpdates section.
+ var routePattern = (context.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText;
+ if (routePattern != null && callEvent.HttpServerRequest != null)
+ {
+ callEvent.HttpServerRequest.NormalizedPathInfo = NormalizePattern(routePattern);
+ Recorder.Instance.Update(callEvent);
+ }
+
+ Recorder.Instance.Add(new Event
+ {
+ EventType = "return",
+ ParentId = callEvent.Id,
+ Elapsed = (Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency,
+ HttpServerResponse = new HttpServerResponse
+ {
+ Status = context.Response.StatusCode,
+ Headers = context.Response.Headers.ToDictionary(
+ h => h.Key, h => h.Value.ToString()),
+ },
+ });
+
+ if (requestRecording)
+ SaveRequestRecording(context);
+ }
+ }
+
+ private static Event BuildRequestEvent(HttpContext context)
+ {
+ var request = context.Request;
+ var e = new Event
+ {
+ EventType = "call",
+ HttpServerRequest = new HttpServerRequest
+ {
+ RequestMethod = request.Method,
+ PathInfo = request.Path.Value ?? "/",
+ Protocol = request.Protocol,
+ Headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString()),
+ },
+ };
+
+ var message = new List();
+ foreach (var (key, values) in request.Query)
+ message.Add(Value.Capture(values.ToString(), name: key, kind: "req"));
+ if (request.HasFormContentType)
+ {
+ foreach (var (key, values) in request.Form)
+ message.Add(Value.Capture(values.ToString(), name: key, kind: "req"));
+ }
+ if (message.Count > 0)
+ e.Message = message;
+ return e;
+ }
+
+ private static void SaveRequestRecording(HttpContext context)
+ {
+ try
+ {
+ var recording = Recorder.Instance.StopLocal();
+ if (recording == null)
+ return;
+ if (recording.EventCount == 0)
+ {
+ recording.Discard();
+ return;
+ }
+ var timestamp = DateTime.Now;
+ var request = context.Request;
+ recording.Metadata.Name =
+ $"{request.Method} {request.Path} ({context.Response.StatusCode}) - "
+ + timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fff");
+ recording.Save($"{timestamp:yyyyMMddHHmmssfff}_{request.Path}");
+ }
+ catch (Exception e)
+ {
+ Logger.Error("failed to save request recording", e);
+ }
+ }
+
+ /// The AppMap spec keeps the framework's native template form, so
+ /// preserve the route text and just strip constraints ("{id:int}" → "{id}").
+ private static string NormalizePattern(string pattern)
+ {
+ var normalized = System.Text.RegularExpressions.Regex.Replace(
+ pattern, @"\{([^}:?]+)[^}]*\}", "{$1}");
+ return normalized.StartsWith('/') ? normalized : "/" + normalized;
+ }
+}
diff --git a/managed/src/AppMap.AspNetCore/RemoteRecordingMiddleware.cs b/managed/src/AppMap.AspNetCore/RemoteRecordingMiddleware.cs
new file mode 100644
index 0000000..ec1b47b
--- /dev/null
+++ b/managed/src/AppMap.AspNetCore/RemoteRecordingMiddleware.cs
@@ -0,0 +1,89 @@
+using AppMap.Output;
+using AppMap.Record;
+using Microsoft.AspNetCore.Http;
+
+namespace AppMap.AspNetCore;
+
+///
+/// Serves the AppMap remote-recording protocol, byte-compatible with
+/// appmap-java's RemoteRecordingManager:
+/// GET /_appmap/record → {"enabled": bool}
+/// POST /_appmap/record → start (409 if already recording)
+/// DELETE /_appmap/record → stop, body is the AppMap JSON (404 if none)
+/// GET /_appmap/record/checkpoint → snapshot without stopping (404 if none)
+///
+public sealed class RemoteRecordingMiddleware
+{
+ public const string RecordRoute = "/_appmap/record";
+ private const string CheckpointRoute = "/_appmap/record/checkpoint";
+
+ private readonly RequestDelegate next;
+
+ public RemoteRecordingMiddleware(RequestDelegate next) => this.next = next;
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var path = context.Request.Path;
+ if (path == CheckpointRoute && HttpMethods.IsGet(context.Request.Method))
+ {
+ await Respond(context, Recorder.Instance.Checkpoint());
+ return;
+ }
+ if (path != RecordRoute)
+ {
+ await next(context);
+ return;
+ }
+
+ switch (context.Request.Method)
+ {
+ case "GET":
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(
+ $"{{\"enabled\":{(Recorder.Instance.HasGlobalSession ? "true" : "false")}}}");
+ break;
+
+ case "POST":
+ if (Recorder.Instance.HasGlobalSession)
+ {
+ context.Response.StatusCode = StatusCodes.Status409Conflict;
+ }
+ else
+ {
+ Recorder.Instance.Start(new Metadata
+ {
+ RecorderName = "remote_recording",
+ RecorderType = "remote",
+ Name = $"Remote recording {DateTime.Now:yyyy-MM-ddTHH:mm:ss}",
+ });
+ }
+ break;
+
+ case "DELETE":
+ await Respond(context, Recorder.Instance.Stop());
+ break;
+
+ default:
+ context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed;
+ break;
+ }
+ }
+
+ private static async Task Respond(HttpContext context, Recording? recording)
+ {
+ if (recording == null)
+ {
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+ try
+ {
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(recording.ToJson());
+ }
+ finally
+ {
+ recording.Discard();
+ }
+ }
+}
diff --git a/managed/src/AppMap.Attributes/AppMap.Attributes.csproj b/managed/src/AppMap.Attributes/AppMap.Attributes.csproj
new file mode 100644
index 0000000..93dbd26
--- /dev/null
+++ b/managed/src/AppMap.Attributes/AppMap.Attributes.csproj
@@ -0,0 +1,11 @@
+
+
+
+ netstandard2.0
+ latest
+ enable
+ AppMap
+ Source-level attributes for the AppMap .NET agent — the analog of appmap-java's annotation artifact. Dependency-free so applications can label code without referencing the agent.
+
+
+
diff --git a/managed/src/AppMap.Attributes/LabelsAttribute.cs b/managed/src/AppMap.Attributes/LabelsAttribute.cs
new file mode 100644
index 0000000..fda542e
--- /dev/null
+++ b/managed/src/AppMap.Attributes/LabelsAttribute.cs
@@ -0,0 +1,23 @@
+using System;
+
+namespace AppMap;
+
+///
+/// Attaches AppMap labels to a method (or to every recorded method of a
+/// class) — the analog of appmap-java's @Labels annotation. Labels appear on
+/// the function's classMap entry and are what AppMap runtime analysis rules
+/// match on (e.g. "security.authentication", "crypto.digest", "log", "crud").
+///
+/// A labeled method is instrumented even when its namespace is not listed
+/// under packages: in appmap.yml, matching the Java agent's behavior. The
+/// agent matches this attribute by full type name ("AppMap.LabelsAttribute"),
+/// so any assembly identity works.
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class,
+ Inherited = false)]
+public sealed class LabelsAttribute : Attribute
+{
+ public LabelsAttribute(params string[] labels) => Labels = labels;
+
+ public string[] Labels { get; }
+}
diff --git a/managed/src/AppMap.StartupHook/AppMap.StartupHook.csproj b/managed/src/AppMap.StartupHook/AppMap.StartupHook.csproj
new file mode 100644
index 0000000..1a23261
--- /dev/null
+++ b/managed/src/AppMap.StartupHook/AppMap.StartupHook.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ DOTNET_STARTUP_HOOKS entry point for the AppMap .NET agent.
+
+ true
+
+
+
+
+
+
+
+
+
diff --git a/managed/src/AppMap.StartupHook/StartupHook.cs b/managed/src/AppMap.StartupHook/StartupHook.cs
new file mode 100644
index 0000000..9ed39e0
--- /dev/null
+++ b/managed/src/AppMap.StartupHook/StartupHook.cs
@@ -0,0 +1,34 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+
+///
+/// .NET startup hook — the direct analog of the Java agent's premain.
+/// Activate with:
+/// DOTNET_STARTUP_HOOKS=/path/to/AppMap.StartupHook.dll dotnet run
+/// The class must be named StartupHook, outside any namespace, with a static
+/// Initialize(): that is the contract the runtime requires.
+///
+internal class StartupHook
+{
+ public static void Initialize()
+ {
+ // The hook assembly is loaded outside the app's dependency context,
+ // so AppMap.Agent.dll and its dependencies won't resolve on their
+ // own. Resolve them from this assembly's directory.
+ var dir = Path.GetDirectoryName(typeof(StartupHook).Assembly.Location)!;
+ AppDomain.CurrentDomain.AssemblyResolve += (_, args) =>
+ {
+ var name = new AssemblyName(args.Name).Name;
+ if (name == null)
+ return null;
+ var candidate = Path.Combine(dir, name + ".dll");
+ return File.Exists(candidate) ? Assembly.LoadFrom(candidate) : null;
+ };
+ Boot();
+ }
+
+ // Kept out of Initialize() so the JIT doesn't resolve AppMap.Agent
+ // before the AssemblyResolve handler is in place.
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ private static void Boot() => AppMap.AgentBootstrap.Init();
+}
diff --git a/managed/src/AppMap.SystemWeb/AppMap.SystemWeb.csproj b/managed/src/AppMap.SystemWeb/AppMap.SystemWeb.csproj
new file mode 100644
index 0000000..bf10235
--- /dev/null
+++ b/managed/src/AppMap.SystemWeb/AppMap.SystemWeb.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net472
+ latest
+ enable
+ AppMap.SystemWeb
+ Classic ASP.NET (System.Web) integration for the AppMap .NET agent: HTTP event recording, per-request AppMaps, and remote recording endpoints via an IHttpModule.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/managed/src/AppMap.SystemWeb/AppMapHttpModule.cs b/managed/src/AppMap.SystemWeb/AppMapHttpModule.cs
new file mode 100644
index 0000000..51552ba
--- /dev/null
+++ b/managed/src/AppMap.SystemWeb/AppMapHttpModule.cs
@@ -0,0 +1,285 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Web;
+using System.Web.Routing;
+using AppMap.Config;
+using AppMap.Output;
+using AppMap.Record;
+using AppMap.Util;
+
+namespace AppMap.SystemWeb;
+
+///
+/// Classic ASP.NET integration — the IHttpModule analog of the ASP.NET Core
+/// middleware (and of appmap-java's servlet hooks). Records
+/// http_server_request/http_server_response events around each request,
+/// writes one AppMap per request, and serves the /_appmap/record remote
+/// recording protocol.
+///
+/// Register in Web.config:
+///
+/// <system.webServer>
+/// <modules>
+/// <add name="AppMap" type="AppMap.SystemWeb.AppMapHttpModule, AppMap.SystemWeb" />
+/// </modules>
+/// </system.webServer>
+///
+///
+public sealed class AppMapHttpModule : IHttpModule
+{
+ private const string RecordRoute = "/_appmap/record";
+ private const string CheckpointRoute = "/_appmap/record/checkpoint";
+
+ private const string CallEventKey = "AppMap.CallEvent";
+ private const string StartTimestampKey = "AppMap.StartTimestamp";
+ private const string RequestRecordingKey = "AppMap.RequestRecording";
+
+ public void Init(HttpApplication application)
+ {
+ AgentBootstrap.Init();
+ application.BeginRequest += (sender, _) => OnBeginRequest(((HttpApplication)sender!).Context);
+ application.EndRequest += (sender, _) => OnEndRequest(((HttpApplication)sender!).Context);
+ }
+
+ public void Dispose() { }
+
+ private static void OnBeginRequest(HttpContext context)
+ {
+ try
+ {
+ var path = context.Request.Path;
+ if (Properties.RecordingRemote
+ && path.StartsWith(RecordRoute, StringComparison.OrdinalIgnoreCase))
+ {
+ HandleRemoteRecording(context);
+ return;
+ }
+
+ var requestRecording = Properties.RecordingRequests;
+ if (requestRecording)
+ {
+ Recorder.Instance.StartLocal(new Metadata
+ {
+ RecorderName = "request_recording",
+ RecorderType = "requests",
+ });
+ }
+
+ var callEvent = BuildRequestEvent(context.Request);
+ Recorder.Instance.Add(callEvent);
+ context.Items[CallEventKey] = callEvent;
+ context.Items[StartTimestampKey] = Stopwatch.GetTimestamp();
+ context.Items[RequestRecordingKey] = requestRecording;
+ }
+ catch (Exception e)
+ {
+ Logger.Error("begin-request hook failed", e);
+ }
+ }
+
+ private static void OnEndRequest(HttpContext context)
+ {
+ try
+ {
+ if (context.Items[CallEventKey] is not Event callEvent)
+ return;
+
+ // The route is resolved after BeginRequest; the call event was
+ // already streamed, so re-record it via eventUpdates.
+ var template = RouteTemplateOf(context);
+ if (template != null && callEvent.HttpServerRequest != null)
+ {
+ callEvent.HttpServerRequest.NormalizedPathInfo = template;
+ Recorder.Instance.Update(callEvent);
+ }
+
+ var start = (long)context.Items[StartTimestampKey]!;
+ Recorder.Instance.Add(new Event
+ {
+ EventType = "return",
+ ParentId = callEvent.Id,
+ Elapsed = (Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency,
+ HttpServerResponse = new HttpServerResponse
+ {
+ Status = context.Response.StatusCode,
+ Headers = HeadersOf(context.Response),
+ },
+ });
+
+ if (context.Items[RequestRecordingKey] is true)
+ SaveRequestRecording(context);
+ }
+ catch (Exception e)
+ {
+ Logger.Error("end-request hook failed", e);
+ }
+ }
+
+ private static Event BuildRequestEvent(HttpRequest request)
+ {
+ var e = new Event
+ {
+ EventType = "call",
+ HttpServerRequest = new HttpServerRequest
+ {
+ RequestMethod = request.HttpMethod,
+ PathInfo = request.Path,
+ Protocol = request.ServerVariables["SERVER_PROTOCOL"],
+ Headers = ToDictionary(request.Headers),
+ },
+ };
+
+ var message = new List();
+ foreach (string? key in request.QueryString)
+ {
+ if (key != null)
+ message.Add(Value.Capture(request.QueryString[key], name: key, kind: "req"));
+ }
+ if (request.ContentType?.IndexOf("form", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ foreach (string? key in request.Form)
+ {
+ if (key != null)
+ message.Add(Value.Capture(request.Form[key], name: key, kind: "req"));
+ }
+ }
+ if (message.Count > 0)
+ e.Message = message;
+ return e;
+ }
+
+ private static void SaveRequestRecording(HttpContext context)
+ {
+ try
+ {
+ var recording = Recorder.Instance.StopLocal();
+ if (recording == null)
+ return;
+ if (recording.EventCount == 0)
+ {
+ recording.Discard();
+ return;
+ }
+ var timestamp = DateTime.Now;
+ var request = context.Request;
+ recording.Metadata.Name =
+ $"{request.HttpMethod} {request.Path} ({context.Response.StatusCode}) - "
+ + timestamp.ToString("yyyy-MM-ddTHH:mm:ss.fff");
+ recording.Save($"{timestamp:yyyyMMddHHmmssfff}_{request.Path}");
+ }
+ catch (Exception e)
+ {
+ Logger.Error("failed to save request recording", e);
+ }
+ }
+
+ private static void HandleRemoteRecording(HttpContext context)
+ {
+ var response = context.Response;
+ var isCheckpoint = context.Request.Path.Equals(
+ CheckpointRoute, StringComparison.OrdinalIgnoreCase);
+
+ switch (context.Request.HttpMethod)
+ {
+ case "GET" when isCheckpoint:
+ Respond(response, Recorder.Instance.Checkpoint());
+ break;
+
+ case "GET":
+ response.ContentType = "application/json";
+ response.Write(Recorder.Instance.HasGlobalSession
+ ? "{\"enabled\":true}" : "{\"enabled\":false}");
+ break;
+
+ case "POST":
+ if (Recorder.Instance.HasGlobalSession)
+ {
+ response.StatusCode = 409;
+ }
+ else
+ {
+ Recorder.Instance.Start(new Metadata
+ {
+ RecorderName = "remote_recording",
+ RecorderType = "remote",
+ Name = $"Remote recording {DateTime.Now:yyyy-MM-ddTHH:mm:ss}",
+ });
+ }
+ break;
+
+ case "DELETE":
+ Respond(response, Recorder.Instance.Stop());
+ break;
+
+ default:
+ response.StatusCode = 405;
+ break;
+ }
+
+ context.ApplicationInstance.CompleteRequest();
+ }
+
+ private static void Respond(HttpResponse response, Recording? recording)
+ {
+ if (recording == null)
+ {
+ response.StatusCode = 404;
+ return;
+ }
+ try
+ {
+ response.ContentType = "application/json";
+ response.Write(recording.ToJson());
+ }
+ finally
+ {
+ recording.Discard();
+ }
+ }
+
+ private static string? RouteTemplateOf(HttpContext context)
+ {
+ try
+ {
+ var route = context.Request.RequestContext?.RouteData?.Route as Route;
+ var url = route?.Url;
+ if (string.IsNullOrEmpty(url))
+ return null;
+ // Route templates have no leading slash; strip constraints as the
+ // Core middleware does ("{id:int}" -> "{id}").
+ var normalized = System.Text.RegularExpressions.Regex.Replace(
+ url, @"\{([^}:?*]+)[^}]*\}", "{$1}");
+ return "/" + normalized;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static Dictionary ToDictionary(
+ System.Collections.Specialized.NameValueCollection headers)
+ {
+ var result = new Dictionary(headers.Count);
+ foreach (string? key in headers)
+ {
+ if (key != null)
+ result[key] = headers[key] ?? string.Empty;
+ }
+ return result;
+ }
+
+ private static Dictionary? HeadersOf(HttpResponse response)
+ {
+ try
+ {
+ // Response.Headers requires the IIS integrated pipeline.
+ return ToDictionary(response.Headers);
+ }
+ catch (PlatformNotSupportedException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/managed/src/AppMap.Testing.NUnit/AppMap.Testing.NUnit.csproj b/managed/src/AppMap.Testing.NUnit/AppMap.Testing.NUnit.csproj
new file mode 100644
index 0000000..7f9d7bf
--- /dev/null
+++ b/managed/src/AppMap.Testing.NUnit/AppMap.Testing.NUnit.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0;net462
+ latest
+ enable
+ enable
+ AppMap.Testing.NUnit
+ NUnit integration for the AppMap .NET agent: one AppMap per test.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/managed/src/AppMap.Testing.NUnit/AppMapAttribute.cs b/managed/src/AppMap.Testing.NUnit/AppMapAttribute.cs
new file mode 100644
index 0000000..b5e78ed
--- /dev/null
+++ b/managed/src/AppMap.Testing.NUnit/AppMapAttribute.cs
@@ -0,0 +1,74 @@
+using AppMap.Output;
+using AppMap.Record;
+using AppMap.Util;
+using NUnit.Framework;
+using NUnit.Framework.Interfaces;
+
+namespace AppMap.Testing.NUnit;
+
+///
+/// Records one AppMap per test method — the analog of appmap-java's TestNG
+/// hooks. Unlike the xUnit integration, NUnit's ITestAction sees the test
+/// outcome, so test_status and test_failure are populated.
+/// [AppMap] public class MyTests { ... }
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)]
+public sealed class AppMapAttribute : Attribute, ITestAction
+{
+ public ActionTargets Targets => ActionTargets.Test;
+
+ private bool startedHere;
+
+ public void BeforeTest(ITest test)
+ {
+ startedHere = false;
+ AgentBootstrap.Init();
+ // Don't displace a remote or process recording already in progress.
+ if (Recorder.Instance.HasGlobalSession || test.Method == null)
+ return;
+ startedHere = true;
+ var definedClass = Value.TypeName(test.Method.MethodInfo.DeclaringType!);
+ var (path, lineno) = SourceLocator.Locate(test.Method.MethodInfo);
+ Recorder.Instance.Start(new Metadata
+ {
+ RecorderName = "nunit",
+ RecorderType = "tests",
+ Name = test.FullName,
+ RecordingDefinedClass = definedClass,
+ RecordingMethodId = test.Method.Name,
+ SourceLocation = path != null && lineno.HasValue ? $"{path}:{lineno}" : null,
+ Frameworks = { new Framework { Name = "NUnit" } },
+ });
+ }
+
+ public void AfterTest(ITest test)
+ {
+ if (!startedHere)
+ return;
+ try
+ {
+ var recording = Recorder.Instance.Stop();
+ if (recording == null || test.Method == null)
+ return;
+
+ var result = TestContext.CurrentContext.Result;
+ recording.Metadata.TestStatus =
+ result.Outcome.Status == TestStatus.Failed ? "failed" : "succeeded";
+ if (result.Outcome.Status == TestStatus.Failed)
+ {
+ recording.Metadata.TestFailure = new TestFailure
+ {
+ Message = result.Message ?? "test failed",
+ Location = recording.Metadata.SourceLocation,
+ };
+ }
+
+ var definedClass = Value.TypeName(test.Method.MethodInfo.DeclaringType!);
+ recording.Save($"{definedClass}_{test.Method.Name}");
+ }
+ catch (Exception e)
+ {
+ Logger.Error("failed to save test recording", e);
+ }
+ }
+}
diff --git a/managed/src/AppMap.Testing.Xunit/AppMap.Testing.Xunit.csproj b/managed/src/AppMap.Testing.Xunit/AppMap.Testing.Xunit.csproj
new file mode 100644
index 0000000..57b18d6
--- /dev/null
+++ b/managed/src/AppMap.Testing.Xunit/AppMap.Testing.Xunit.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0;net462
+ latest
+ enable
+ enable
+ AppMap.Testing.Xunit
+ xUnit integration for the AppMap .NET agent: one AppMap per test.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/managed/src/AppMap.Testing.Xunit/AppMapAttribute.cs b/managed/src/AppMap.Testing.Xunit/AppMapAttribute.cs
new file mode 100644
index 0000000..1f18d71
--- /dev/null
+++ b/managed/src/AppMap.Testing.Xunit/AppMapAttribute.cs
@@ -0,0 +1,62 @@
+using System.Reflection;
+using AppMap.Output;
+using AppMap.Record;
+using AppMap.Util;
+using Xunit.Sdk;
+
+namespace AppMap.Testing.Xunit;
+
+///
+/// Records one AppMap per test method — the analog of appmap-java's JUnit
+/// hooks. Apply to a method, class, or assembly:
+/// [AppMap] public class MyTests { ... }
+/// Caveat: xUnit's BeforeAfterTestAttribute does not expose the test
+/// outcome, so test_status is "succeeded" unless the framework reports
+/// otherwise downstream. Disable parallelization for coherent maps.
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)]
+public sealed class AppMapAttribute : BeforeAfterTestAttribute
+{
+ private bool startedHere;
+
+ public override void Before(MethodInfo methodUnderTest)
+ {
+ startedHere = false;
+ AgentBootstrap.Init();
+ // Don't displace a remote or process recording already in progress.
+ if (Recorder.Instance.HasGlobalSession)
+ return;
+ startedHere = true;
+ var definedClass = Value.TypeName(methodUnderTest.DeclaringType!);
+ var (path, lineno) = SourceLocator.Locate(methodUnderTest);
+ Recorder.Instance.Start(new Metadata
+ {
+ RecorderName = "xunit",
+ RecorderType = "tests",
+ Name = $"{definedClass}.{methodUnderTest.Name}",
+ RecordingDefinedClass = definedClass,
+ RecordingMethodId = methodUnderTest.Name,
+ SourceLocation = path != null && lineno.HasValue ? $"{path}:{lineno}" : null,
+ Frameworks = { new Framework { Name = "xunit" } },
+ });
+ }
+
+ public override void After(MethodInfo methodUnderTest)
+ {
+ if (!startedHere)
+ return;
+ try
+ {
+ var recording = Recorder.Instance.Stop();
+ if (recording == null)
+ return;
+ recording.Metadata.TestStatus = "succeeded";
+ var definedClass = Value.TypeName(methodUnderTest.DeclaringType!);
+ recording.Save($"{definedClass}_{methodUnderTest.Name}");
+ }
+ catch (Exception e)
+ {
+ Logger.Error("failed to save test recording", e);
+ }
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/AppMap.Agent.Tests.csproj b/managed/test/AppMap.Agent.Tests/AppMap.Agent.Tests.csproj
new file mode 100644
index 0000000..97c1d6a
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/AppMap.Agent.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/managed/test/AppMap.Agent.Tests/AppMapConfigTests.cs b/managed/test/AppMap.Agent.Tests/AppMapConfigTests.cs
new file mode 100644
index 0000000..96a2a1a
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/AppMapConfigTests.cs
@@ -0,0 +1,63 @@
+using AppMap.Config;
+using Xunit;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace AppMap.Agent.Tests;
+
+public class AppMapConfigTests
+{
+ private static AppMapConfig Parse(string yaml) =>
+ new DeserializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .IgnoreUnmatchedProperties()
+ .Build()
+ .Deserialize(yaml);
+
+ [Fact]
+ public void ParsesPackagesWithExcludes()
+ {
+ var config = Parse("""
+ name: my-app
+ packages:
+ - path: MyApp
+ exclude:
+ - MyApp.Generated
+ - path: OtherLib.Core
+ shallow: true
+ """);
+
+ Assert.Equal("my-app", config.Name);
+ Assert.Equal(2, config.Packages.Count);
+ Assert.NotNull(config.FindPackage("MyApp.Services.UserService.Find"));
+ Assert.Null(config.FindPackage("MyApp.Generated.Dto.Build"));
+ Assert.Null(config.FindPackage("MyAppOther.Thing.Do"));
+ Assert.True(config.FindPackage("OtherLib.Core.Engine.Run")!.Shallow);
+ }
+
+ [Fact]
+ public void MethodsListRestrictsAndLabels()
+ {
+ var config = Parse("""
+ name: my-app
+ packages:
+ - path: MyApp
+ methods:
+ - class: .*Service
+ name: Find.*
+ labels: [crud]
+ """);
+
+ var pkg = config.FindPackage("MyApp.UserService.FindUser");
+ Assert.NotNull(pkg);
+ Assert.Equal(new[] { "crud" }, pkg!.LabelsFor("MyApp.UserService.FindUser"));
+ Assert.Null(config.FindPackage("MyApp.UserService.DeleteUser"));
+ }
+
+ [Fact]
+ public void DefaultAppMapDir()
+ {
+ var config = Parse("name: x");
+ Assert.Equal("tmp/appmap", config.AppMapDir);
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/AppMapSerializerTests.cs b/managed/test/AppMap.Agent.Tests/AppMapSerializerTests.cs
new file mode 100644
index 0000000..24475ee
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/AppMapSerializerTests.cs
@@ -0,0 +1,140 @@
+using System.Text.Json;
+using AppMap.Output;
+using AppMap.Record;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+public class AppMapSerializerTests
+{
+ private static JsonDocument Serialize(Metadata md, List events, CodeObjectTree tree)
+ {
+ using var buffer = new MemoryStream();
+ AppMapSerializer.Write(buffer, md, events, tree);
+ return JsonDocument.Parse(buffer.ToArray());
+ }
+
+ [Fact]
+ public void WritesFormatVersionAndMetadata()
+ {
+ var doc = Serialize(
+ new Metadata { RecorderName = "xunit", RecorderType = "tests", App = "demo" },
+ new List(), new CodeObjectTree());
+ var root = doc.RootElement;
+
+ Assert.Equal("1.2", root.GetProperty("version").GetString());
+ var metadata = root.GetProperty("metadata");
+ Assert.Equal("demo", metadata.GetProperty("app").GetString());
+ Assert.Equal("csharp", metadata.GetProperty("language").GetProperty("name").GetString());
+ Assert.Equal("appmap-dotnet",
+ metadata.GetProperty("client").GetProperty("name").GetString());
+ Assert.Equal("xunit", metadata.GetProperty("recorder").GetProperty("name").GetString());
+ Assert.Equal("tests", metadata.GetProperty("recorder").GetProperty("type").GetString());
+ }
+
+ [Fact]
+ public void WritesCallAndReturnEventsInSnakeCase()
+ {
+ var call = new Event
+ {
+ EventType = "call",
+ DefinedClass = "Demo.Service",
+ MethodId = "DoWork",
+ Static = false,
+ Parameters = new List
+ {
+ Value.Capture(42, name: "count", declaredType: typeof(int), kind: "req"),
+ },
+ };
+ var ret = new Event
+ {
+ EventType = "return",
+ ParentId = call.Id,
+ Elapsed = 0.25,
+ ReturnValue = Value.Capture("ok", declaredType: typeof(string)),
+ };
+
+ var doc = Serialize(
+ new Metadata { RecorderName = "tests", RecorderType = "tests" },
+ new List { call, ret }, new CodeObjectTree());
+ var events = doc.RootElement.GetProperty("events");
+
+ Assert.Equal(2, events.GetArrayLength());
+ var callJson = events[0];
+ Assert.Equal("call", callJson.GetProperty("event").GetString());
+ Assert.Equal("Demo.Service", callJson.GetProperty("defined_class").GetString());
+ Assert.Equal("DoWork", callJson.GetProperty("method_id").GetString());
+ var param = callJson.GetProperty("parameters")[0];
+ Assert.Equal("count", param.GetProperty("name").GetString());
+ Assert.Equal("42", param.GetProperty("value").GetString());
+ Assert.Equal("System.Int32", param.GetProperty("class").GetString());
+
+ var retJson = events[1];
+ Assert.Equal("return", retJson.GetProperty("event").GetString());
+ Assert.Equal(call.Id, retJson.GetProperty("parent_id").GetInt32());
+ Assert.Equal(0.25, retJson.GetProperty("elapsed").GetDouble());
+ Assert.Equal("ok", retJson.GetProperty("return_value").GetProperty("value").GetString());
+ }
+
+ [Fact]
+ public void WritesHttpAndSqlEvents()
+ {
+ var call = new Event
+ {
+ EventType = "call",
+ HttpServerRequest = new HttpServerRequest
+ {
+ RequestMethod = "GET",
+ PathInfo = "/users/3",
+ NormalizedPathInfo = "/users/{id}",
+ Protocol = "HTTP/1.1",
+ },
+ };
+ var sql = new Event
+ {
+ EventType = "call",
+ SqlQuery = new SqlQuery { Sql = "SELECT 1", DatabaseType = "sqlite" },
+ };
+
+ var doc = Serialize(
+ new Metadata { RecorderName = "request_recording", RecorderType = "requests" },
+ new List { call, sql }, new CodeObjectTree());
+ var events = doc.RootElement.GetProperty("events");
+
+ var req = events[0].GetProperty("http_server_request");
+ Assert.Equal("GET", req.GetProperty("request_method").GetString());
+ Assert.Equal("/users/{id}", req.GetProperty("normalized_path_info").GetString());
+
+ var query = events[1].GetProperty("sql_query");
+ Assert.Equal("SELECT 1", query.GetProperty("sql").GetString());
+ Assert.Equal("sqlite", query.GetProperty("database_type").GetString());
+ }
+
+ [Fact]
+ public void BuildsNestedClassMap()
+ {
+ var tree = new CodeObjectTree();
+ tree.RegisterFunction("Demo.Services", new[] { "UserService" },
+ "FindUser", false, "Services/UserService.cs:12", new[] { "crud" });
+ tree.RegisterFunction("Demo.Services", new[] { "UserService" },
+ "FindUser", false, "Services/UserService.cs:12", null); // duplicate
+
+ var doc = Serialize(
+ new Metadata { RecorderName = "tests", RecorderType = "tests" },
+ new List(), tree);
+ var classMap = doc.RootElement.GetProperty("classMap");
+
+ var demo = classMap[0];
+ Assert.Equal("package", demo.GetProperty("type").GetString());
+ Assert.Equal("Demo", demo.GetProperty("name").GetString());
+ var services = demo.GetProperty("children")[0];
+ Assert.Equal("Services", services.GetProperty("name").GetString());
+ var cls = services.GetProperty("children")[0];
+ Assert.Equal("class", cls.GetProperty("type").GetString());
+ var fn = cls.GetProperty("children");
+ Assert.Equal(1, fn.GetArrayLength());
+ Assert.Equal("function", fn[0].GetProperty("type").GetString());
+ Assert.Equal("Services/UserService.cs:12", fn[0].GetProperty("location").GetString());
+ Assert.Equal("crud", fn[0].GetProperty("labels")[0].GetString());
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/AsyncResultTests.cs b/managed/test/AppMap.Agent.Tests/AsyncResultTests.cs
new file mode 100644
index 0000000..60a1ad4
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/AsyncResultTests.cs
@@ -0,0 +1,86 @@
+using AppMap.Instrumentation;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+public class AsyncResultTests
+{
+ [Fact]
+ public void RecognizesTaskAndValueTaskAsAwaitable()
+ {
+ Assert.NotNull(AsyncResult.AsTask(Task.CompletedTask));
+ Assert.NotNull(AsyncResult.AsTask(Task.FromResult(42)));
+ Assert.NotNull(AsyncResult.AsTask(new ValueTask(Task.CompletedTask)));
+ Assert.NotNull(AsyncResult.AsTask(new ValueTask(7)));
+ }
+
+ [Fact]
+ public void NonAwaitablesAreNotTasks()
+ {
+ Assert.Null(AsyncResult.AsTask(null));
+ Assert.Null(AsyncResult.AsTask(42));
+ Assert.Null(AsyncResult.AsTask("hello"));
+ Assert.Null(AsyncResult.AsTask(new object()));
+ }
+
+ [Fact]
+ public void UnwrapsGenericResult()
+ {
+ var (value, type, ex) = AsyncResult.Unwrap(Task.FromResult(123));
+ Assert.Equal(123, value);
+ Assert.Equal(typeof(int), type);
+ Assert.Null(ex);
+ }
+
+ [Fact]
+ public void NonGenericTaskHasNoValue()
+ {
+ var (value, type, ex) = AsyncResult.Unwrap(Task.CompletedTask);
+ Assert.Null(value);
+ Assert.Null(type);
+ Assert.Null(ex);
+ }
+
+ [Fact]
+ public void UnwrapsFaultToFirstInnerException()
+ {
+ var task = Task.FromException(new InvalidOperationException("boom"));
+ var (value, _, ex) = AsyncResult.Unwrap(task);
+ Assert.Null(value);
+ Assert.IsType(ex);
+ Assert.Equal("boom", ex!.Message);
+ }
+
+ [Fact]
+ public void UnwrapsCancellation()
+ {
+ var source = new TaskCompletionSource();
+ source.SetCanceled();
+ var (_, _, ex) = AsyncResult.Unwrap(source.Task);
+ Assert.IsType(ex);
+ }
+
+ [Fact]
+ public async Task UnwrapsValueTaskOfTViaAsTask()
+ {
+ async ValueTask Make() { await Task.Yield(); return "done"; }
+ var task = AsyncResult.AsTask(Make());
+ Assert.NotNull(task);
+ await task!;
+ var (value, type, ex) = AsyncResult.Unwrap(task);
+ Assert.Equal("done", value);
+ Assert.Equal(typeof(string), type);
+ Assert.Null(ex);
+ }
+
+ [Fact]
+ public void VoidTaskResultIsTreatedAsValueless()
+ {
+ // `await Task.Run(() => {})` boxes Task internally.
+ var task = (Task)Task.Run(() => { });
+ task.Wait();
+ var (value, type, _) = AsyncResult.Unwrap(task);
+ Assert.Null(value);
+ Assert.Null(type);
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/HostingStartupTests.cs b/managed/test/AppMap.Agent.Tests/HostingStartupTests.cs
new file mode 100644
index 0000000..4ad5f50
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/HostingStartupTests.cs
@@ -0,0 +1,44 @@
+using AppMap.AspNetCore;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+///
+/// Unit coverage for the zero-touch HostingStartup wiring. The end-to-end
+/// behavior (an unmodified app actually recording HTTP + SQL via env vars
+/// only) is exercised by the web target in the harness, which runs in CI.
+/// These tests avoid triggering the real agent pipeline — calling UseAppMap
+/// would instrument the test process — so they verify the DI registration
+/// the HostingStartup performs.
+///
+public class HostingStartupTests
+{
+ [Fact]
+ public void HostingStartupRegistersTheStartupFilter()
+ {
+ var builder = WebApplication.CreateBuilder();
+ new AppMapHostingStartup().Configure(builder.WebHost);
+
+ var app = builder.Build();
+ var filters = app.Services.GetServices();
+
+ // The filter is internal; identify it by name without widening the API.
+ Assert.Contains(filters, f => f.GetType().Name == "AppMapStartupFilter");
+ }
+
+ [Fact]
+ public void StartupFilterIsRegisteredExactlyOnce()
+ {
+ var builder = WebApplication.CreateBuilder();
+ new AppMapHostingStartup().Configure(builder.WebHost);
+
+ var app = builder.Build();
+ var ours = app.Services.GetServices()
+ .Count(f => f.GetType().Name == "AppMapStartupFilter");
+
+ Assert.Equal(1, ours);
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/LabelsTests.cs b/managed/test/AppMap.Agent.Tests/LabelsTests.cs
new file mode 100644
index 0000000..ca3682f
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/LabelsTests.cs
@@ -0,0 +1,138 @@
+using System.Security.Cryptography;
+using AppMap.Instrumentation;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+[Labels("class-label")]
+internal class LabeledService
+{
+ [Labels("method-label", "class-label")] // duplicate of the class label
+ public void Save() { }
+
+ public void Plain() { }
+}
+
+internal class UnlabeledService
+{
+ public void Plain() { }
+}
+
+public class AttributeLabelsTests
+{
+ [Fact]
+ public void MergesClassAndMethodLabelsWithoutDuplicates()
+ {
+ var labels = AttributeLabels.Of(typeof(LabeledService).GetMethod("Save")!);
+ Assert.Equal(new[] { "class-label", "method-label" }, labels);
+ }
+
+ [Fact]
+ public void ClassLabelAppliesToUnattributedMethod()
+ {
+ var labels = AttributeLabels.Of(typeof(LabeledService).GetMethod("Plain")!);
+ Assert.Equal(new[] { "class-label" }, labels);
+ }
+
+ [Fact]
+ public void ReturnsNullWhenNoAttribute()
+ {
+ Assert.Null(AttributeLabels.Of(typeof(UnlabeledService).GetMethod("Plain")!));
+ }
+
+ [Fact]
+ public void MergeCombinesConfigAndAttributeLabels()
+ {
+ Assert.Equal(new[] { "crud", "audit" },
+ AttributeLabels.Merge(new[] { "crud" }, new[] { "audit", "crud" }));
+ Assert.Equal(new[] { "crud" }, AttributeLabels.Merge(new[] { "crud" }, null));
+ Assert.Equal(new[] { "crud" }, AttributeLabels.Merge(null, new[] { "crud" }));
+ Assert.Null(AttributeLabels.Merge(null, null));
+ }
+}
+
+public class BuiltinHooksTests
+{
+ private sealed class FakeSession : Microsoft.AspNetCore.Http.ISession
+ {
+ public bool IsAvailable => true;
+ public string Id => "";
+ public IEnumerable Keys => Array.Empty();
+ public void Clear() { }
+ public Task CommitAsync(CancellationToken token = default) => Task.CompletedTask;
+ public Task LoadAsync(CancellationToken token = default) => Task.CompletedTask;
+ public void Remove(string key) { }
+ public void Set(string key, byte[] value) { }
+ public bool TryGetValue(string key, out byte[]? value)
+ {
+ value = null;
+ return false;
+ }
+ }
+
+ private static IEnumerable RulesMatching(Type type) =>
+ BuiltinHooks.Rules.Where(r => r.Matches(type));
+
+ [Fact]
+ public void HashAlgorithmSubclassesMatchTheDigestRule()
+ {
+ // SHA256.Create() returns an internal implementation type; the rule
+ // must match through the abstract HashAlgorithm base.
+ using var sha = SHA256.Create();
+ Assert.Contains(RulesMatching(sha.GetType()),
+ r => r.Labels.Contains("crypto.digest"));
+ // ComputeHash is declared concrete on the abstract base itself.
+ Assert.Contains(RulesMatching(typeof(HashAlgorithm)),
+ r => r.Labels.Contains("crypto.digest"));
+ }
+
+ [Fact]
+ public void SymmetricAlgorithmMatchesEncryptAndDecryptRules()
+ {
+ using var aes = System.Security.Cryptography.Aes.Create();
+ var labels = RulesMatching(aes.GetType()).SelectMany(r => r.Labels).ToList();
+ Assert.Contains("crypto.encrypt", labels);
+ Assert.Contains("crypto.decrypt", labels);
+ }
+
+ [Fact]
+ public void SessionImplementationsMatchThroughTheInterface()
+ {
+ var labels = RulesMatching(typeof(FakeSession)).SelectMany(r => r.Labels).ToList();
+ Assert.Contains("http.session.read", labels);
+ Assert.Contains("http.session.write", labels);
+ }
+
+ [Fact]
+ public void HttpClientMatchesTheOutboundRule()
+ {
+ var labels = RulesMatching(typeof(System.Net.Http.HttpClient))
+ .SelectMany(r => r.Labels).ToList();
+ Assert.Contains("http.client.request", labels);
+ }
+
+ [Fact]
+ public void XmlSerializerMatchesTheDeserializeRule()
+ {
+ var labels = RulesMatching(typeof(System.Xml.Serialization.XmlSerializer))
+ .SelectMany(r => r.Labels).ToList();
+ Assert.Contains("deserialize", labels);
+ }
+
+ [Fact]
+ public void EveryRuleHasTypeMethodsAndLabels()
+ {
+ foreach (var rule in BuiltinHooks.Rules)
+ {
+ Assert.False(string.IsNullOrEmpty(rule.Type));
+ Assert.NotEmpty(rule.Methods);
+ Assert.NotEmpty(rule.Labels);
+ }
+ }
+
+ [Fact]
+ public void UnrelatedTypesMatchNoRule()
+ {
+ Assert.Empty(RulesMatching(typeof(UnlabeledService)));
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/RecordingSessionTests.cs b/managed/test/AppMap.Agent.Tests/RecordingSessionTests.cs
new file mode 100644
index 0000000..bc2c8a8
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/RecordingSessionTests.cs
@@ -0,0 +1,126 @@
+using System.Text.Json;
+using AppMap.Instrumentation;
+using AppMap.Output;
+using AppMap.Record;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+public class RecordingSessionTests
+{
+ private static RecordingSession NewSession() => new(new Metadata
+ {
+ RecorderName = "tests",
+ RecorderType = "tests",
+ });
+
+ private static JsonDocument Render(Recording recording)
+ {
+ using var buffer = new MemoryStream();
+ recording.WriteTo(buffer);
+ recording.Discard();
+ return JsonDocument.Parse(buffer.ToArray());
+ }
+
+ [Fact]
+ public void StreamsEventsToTempFileAndRendersValidDocument()
+ {
+ var session = NewSession();
+ session.Add(new Event { EventType = "call", DefinedClass = "A", MethodId = "M" }, null);
+ session.Add(new Event { EventType = "return", ParentId = 1 }, null);
+
+ var recording = session.Finish();
+ Assert.Equal(2, recording.EventCount);
+
+ using var doc = Render(recording);
+ var events = doc.RootElement.GetProperty("events");
+ Assert.Equal(2, events.GetArrayLength());
+ Assert.Equal("call", events[0].GetProperty("event").GetString());
+ Assert.Equal("return", events[1].GetProperty("event").GetString());
+ Assert.False(doc.RootElement.TryGetProperty("eventUpdates", out _));
+ }
+
+ [Fact]
+ public void UpdatedEventsAppearInEventUpdates()
+ {
+ var session = NewSession();
+ var call = new Event
+ {
+ EventType = "call",
+ HttpServerRequest = new HttpServerRequest
+ {
+ RequestMethod = "GET",
+ PathInfo = "/owners/Davis",
+ },
+ };
+ session.Add(call, null);
+
+ // Route template discovered after the event was streamed.
+ call.HttpServerRequest.NormalizedPathInfo = "/owners/{lastName}";
+ session.Update(call);
+
+ using var doc = Render(session.Finish());
+
+ // The streamed copy lacks the route; the update carries it.
+ var streamed = doc.RootElement.GetProperty("events")[0];
+ Assert.False(streamed.GetProperty("http_server_request")
+ .TryGetProperty("normalized_path_info", out _));
+
+ var updated = doc.RootElement.GetProperty("eventUpdates")
+ .GetProperty(call.Id.ToString());
+ Assert.Equal("/owners/{lastName}", updated.GetProperty("http_server_request")
+ .GetProperty("normalized_path_info").GetString());
+ }
+
+ [Fact]
+ public void EmptySessionRendersEmptyEventsArray()
+ {
+ using var doc = Render(NewSession().Finish());
+ Assert.Equal(0, doc.RootElement.GetProperty("events").GetArrayLength());
+ }
+
+ [Fact]
+ public void SnapshotLeavesSessionRecording()
+ {
+ var session = NewSession();
+ session.Add(new Event { EventType = "call" }, null);
+
+ var checkpoint = session.Snapshot();
+ session.Add(new Event { EventType = "return", ParentId = 1 }, null);
+ var final = session.Finish();
+
+ using var checkpointDoc = Render(checkpoint);
+ using var finalDoc = Render(final);
+ Assert.Equal(1, checkpointDoc.RootElement.GetProperty("events").GetArrayLength());
+ Assert.Equal(2, finalDoc.RootElement.GetProperty("events").GetArrayLength());
+ }
+}
+
+public class DefaultExcludesTests
+{
+ private sealed class Sample
+ {
+ public override string ToString() => "sample";
+
+ public override int GetHashCode() => 1;
+
+ public void DoWork() { }
+
+ [Labels("important")]
+ public override bool Equals(object? obj) => false;
+ }
+
+ [Fact]
+ public void TrivialOverridesAreExcluded()
+ {
+ Assert.True(Instrumentor.IsDefaultExcluded(typeof(Sample).GetMethod("ToString")!));
+ Assert.True(Instrumentor.IsDefaultExcluded(typeof(Sample).GetMethod("GetHashCode")!));
+ Assert.False(Instrumentor.IsDefaultExcluded(typeof(Sample).GetMethod("DoWork")!));
+ }
+
+ [Fact]
+ public void LabeledMethodsWinOverDefaultExcludes()
+ {
+ Assert.False(Instrumentor.IsDefaultExcluded(typeof(Sample).GetMethod("Equals")!));
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/SourceLocatorTests.cs b/managed/test/AppMap.Agent.Tests/SourceLocatorTests.cs
new file mode 100644
index 0000000..9c20f4b
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/SourceLocatorTests.cs
@@ -0,0 +1,72 @@
+using System.Runtime.InteropServices;
+using AppMap.Util;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+public class SourceLocatorTests
+{
+ [Fact]
+ public void RelativizesLinuxPathAgainstProjectRoot()
+ {
+ Assert.Equal("src/Web/Index.cs", SourceLocator.RelativizeAgainst(
+ "/home/user/eShopOnWeb/src/Web/Index.cs",
+ new[] { "/home/user/eShopOnWeb" }));
+ }
+
+ [Fact]
+ public void RelativizesWindowsPathWithForwardSlashes()
+ {
+ // A map recorded on Windows must query identically on Linux.
+ Assert.Equal("src/Web/Index.cs", SourceLocator.RelativizeAgainst(
+ @"C:\agent\work\eShopOnWeb\src\Web\Index.cs",
+ new[] { @"C:\agent\work\eShopOnWeb" }));
+ }
+
+ [Fact]
+ public void PrefersTheFirstMatchingRoot()
+ {
+ // First root wins; ResolveRoots lists the git/repo root first.
+ Assert.Equal("service/Index.cs", SourceLocator.RelativizeAgainst(
+ "/repo/service/Index.cs",
+ new[] { "/repo", "/repo/service" }));
+ }
+
+ [Fact]
+ public void LeavesOutOfTreePathsButNormalizesSlashes()
+ {
+ Assert.Equal("D:/nuget/lib/Foo.cs", SourceLocator.RelativizeAgainst(
+ @"D:\nuget\lib\Foo.cs", new[] { @"C:\app" }));
+ }
+
+ [Fact]
+ public void WindowsPdbFallbackIsGatedToWindows()
+ {
+ Assert.Equal(
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows),
+ WindowsPdbReader.IsSupported);
+ }
+
+ [Fact]
+ public void WindowsPdbReaderNeverThrowsAndReturnsNullOffWindows()
+ {
+ // The whole point of the guard: on non-Windows the COM binder is
+ // never touched, so Open is a safe no-op rather than a P/Invoke
+ // failure. On Windows this still must not throw for our own assembly.
+ var reader = WindowsPdbReader.Open(typeof(SourceLocatorTests).Assembly);
+ if (!WindowsPdbReader.IsSupported)
+ Assert.Null(reader);
+ }
+
+ [Fact]
+ public void PortablePdbStillResolvesThisAssembly()
+ {
+ // The test assembly is built with a portable PDB (SDK default), so
+ // the primary path keeps working alongside the fallback.
+ var method = typeof(SourceLocatorTests).GetMethod(nameof(PortablePdbStillResolvesThisAssembly))!;
+ var (path, line) = SourceLocator.Locate(method);
+ Assert.NotNull(path);
+ Assert.EndsWith("SourceLocatorTests.cs", path);
+ Assert.True(line > 0);
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/SqlHooksTests.cs b/managed/test/AppMap.Agent.Tests/SqlHooksTests.cs
new file mode 100644
index 0000000..c1de8bc
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/SqlHooksTests.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Reflection;
+using AppMap.Instrumentation;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+public class SqlHooksTests
+{
+ [Fact]
+ public void PartialTypeLoadKeepsTheLoadableTypes()
+ {
+ // The Microsoft.Data.SqlClient-on-Linux shape: most types load, one
+ // does not. Before the fix, GetTypes() throwing made SqlHooks discard
+ // the whole assembly — so SqlCommand was never patched and zero SQL
+ // was recorded against SQL Server. The loadable types must survive.
+ var ex = new ReflectionTypeLoadException(
+ new Type?[] { typeof(string), null, typeof(int) },
+ new Exception?[] { null, new TypeLoadException("unloadable"), null });
+
+ var types = SqlHooks.LoadableTypes(ex);
+
+ Assert.Equal(new[] { typeof(string), typeof(int) }, types);
+ }
+}
diff --git a/managed/test/AppMap.Agent.Tests/ValueTests.cs b/managed/test/AppMap.Agent.Tests/ValueTests.cs
new file mode 100644
index 0000000..4dfd620
--- /dev/null
+++ b/managed/test/AppMap.Agent.Tests/ValueTests.cs
@@ -0,0 +1,50 @@
+using AppMap.Output;
+using Xunit;
+
+namespace AppMap.Agent.Tests;
+
+public class ValueTests
+{
+ [Fact]
+ public void CapturesNull()
+ {
+ var v = Value.Capture(null, name: "arg", declaredType: typeof(string));
+ Assert.Equal("null", v.StringValue);
+ Assert.Equal("System.String", v.Class);
+ Assert.Null(v.ObjectId);
+ }
+
+ [Fact]
+ public void ValueTypesHaveNoObjectId()
+ {
+ Assert.Null(Value.Capture(7).ObjectId);
+ Assert.NotNull(Value.Capture(new object()).ObjectId);
+ }
+
+ [Fact]
+ public void TruncatesLongValues()
+ {
+ var v = Value.Capture(new string('x', 5000));
+ Assert.NotNull(v.StringValue);
+ Assert.Equal(1024, v.StringValue!.Length);
+ Assert.EndsWith("...", v.StringValue);
+ }
+
+ private sealed class Hostile
+ {
+ public override string ToString() => throw new InvalidOperationException("nope");
+ }
+
+ [Fact]
+ public void SurvivesThrowingToString()
+ {
+ Assert.Equal("< invalid >", Value.Capture(new Hostile()).StringValue);
+ }
+
+ [Fact]
+ public void NestedTypeNamesUseDots()
+ {
+ Assert.Equal("AppMap.Agent.Tests.ValueTests.Hostile",
+ Value.TypeName(typeof(Hostile)));
+ }
+}