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` | + +![GET /owners sequence diagram](get-owners.sequence.png) 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))); + } +}