From 93640c70fe4c064f4bb1216f010843cab74229c3 Mon Sep 17 00:00:00 2001 From: evlawler <4238658+evlawler@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:48:35 +0000 Subject: [PATCH] Add managed C# AppMap agent under managed/ Imports the fully managed .NET AppMap agent (a from-scratch C# port of appmap-java's architecture) developed in the Fun-with-Appmap-and-Claude repo. Placed under managed/ alongside the existing C++ CLR-profiler prototype so the two implementations can be compared without disturbing the prototype or its CI. Includes the agent (config, recorder, event model, serializer, Harmony instrumentation, SQL/built-in hooks), startup hook, ASP.NET Core and System.Web integrations, xUnit/NUnit test recorders, examples (HelloAppMap, PetClinic), unit tests, and the eShopOnWeb harness. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R1CtA2aMGjPuRYpfv2VcaX --- managed/.github/workflows/ci.yml | 215 ++++++++ managed/.github/workflows/harness.yml | 84 ++++ managed/.gitignore | 13 + managed/AppMap.sln | 84 ++++ managed/BACKLOG.md | 128 +++++ managed/DESIGN.md | 267 ++++++++++ managed/README.md | 263 ++++++++++ .../examples/HelloAppMap/HelloAppMap.csproj | 12 + managed/examples/HelloAppMap/Program.cs | 27 + managed/examples/HelloAppMap/appmap.yml | 3 + .../PetClinic/Controllers/OwnersController.cs | 27 + .../PetClinic/Controllers/VetsController.cs | 20 + .../PetClinic/Data/PetClinicContext.cs | 13 + managed/examples/PetClinic/Data/SeedData.cs | 34 ++ managed/examples/PetClinic/Models/Owner.cs | 10 + managed/examples/PetClinic/Models/Pet.cs | 14 + managed/examples/PetClinic/Models/Vet.cs | 9 + managed/examples/PetClinic/PetClinic.csproj | 20 + managed/examples/PetClinic/Program.cs | 27 + .../PetClinic/Services/OwnerService.cs | 39 ++ .../examples/PetClinic/Services/VetService.cs | 23 + managed/examples/PetClinic/appmap.yml | 4 + managed/examples/PetClinic/docs/README.md | 57 +++ .../docs/get-owner-by-lastname.sequence.png | Bin 0 -> 44163 bytes .../PetClinic/docs/get-owners.sequence.png | Bin 0 -> 45308 bytes .../PetClinic/docs/get-owners.sequence.uml | 57 +++ .../docs/get-vets-async.sequence.png | Bin 0 -> 30802 bytes .../PetClinic/docs/get-vets.sequence.png | Bin 0 -> 41253 bytes .../PetClinic/docs/post-owners.sequence.png | Bin 0 -> 37119 bytes managed/harness/README.md | 149 ++++++ managed/harness/fixtures/SqlServerWeb/Data.cs | 50 ++ .../harness/fixtures/SqlServerWeb/Program.cs | 49 ++ .../fixtures/SqlServerWeb/SqlServerWeb.csproj | 21 + .../fixtures/SystemWebApp/Default.aspx | 2 + .../harness/fixtures/SystemWebApp/appmap.yml | 4 + .../harness/fixtures/SystemWebApp/web.config | 11 + .../harness/fixtures/ZeroTouchWeb/Program.cs | 24 + .../harness/fixtures/ZeroTouchWeb/Widgets.cs | 53 ++ .../fixtures/ZeroTouchWeb/ZeroTouchWeb.csproj | 20 + managed/harness/run.py | 469 ++++++++++++++++++ managed/harness/targets/eshoponweb.json | 17 + managed/harness/targets/sqlserver-web.json | 20 + managed/harness/targets/zerotouch-web.json | 21 + managed/src/AppMap.Agent/AgentBootstrap.cs | 68 +++ managed/src/AppMap.Agent/AppMap.Agent.csproj | 36 ++ .../src/AppMap.Agent/Config/AppMapConfig.cs | 160 ++++++ managed/src/AppMap.Agent/Config/Properties.cs | 58 +++ .../Instrumentation/AsyncResult.cs | 84 ++++ .../Instrumentation/AttributeLabels.cs | 82 +++ .../Instrumentation/BuiltinHooks.cs | 236 +++++++++ .../Instrumentation/EventTemplateRegistry.cs | 113 +++++ .../Instrumentation/HookPatcher.cs | 46 ++ .../Instrumentation/Instrumentor.cs | 168 +++++++ .../Instrumentation/MethodHooks.cs | 140 ++++++ .../AppMap.Agent/Instrumentation/SqlHooks.cs | 203 ++++++++ .../src/AppMap.Agent/Output/CodeObjectTree.cs | 81 +++ managed/src/AppMap.Agent/Output/Event.cs | 109 ++++ managed/src/AppMap.Agent/Output/Metadata.cs | 49 ++ managed/src/AppMap.Agent/Output/Value.cs | 61 +++ managed/src/AppMap.Agent/Polyfills.cs | 28 ++ .../AppMap.Agent/Record/AppMapSerializer.cs | 321 ++++++++++++ managed/src/AppMap.Agent/Record/Recorder.cs | 173 +++++++ managed/src/AppMap.Agent/Record/Recording.cs | 107 ++++ managed/src/AppMap.Agent/Util/GitMetadata.cs | 129 +++++ managed/src/AppMap.Agent/Util/Logger.cs | 33 ++ .../src/AppMap.Agent/Util/SourceLocator.cs | 161 ++++++ .../src/AppMap.Agent/Util/WindowsPdbReader.cs | 178 +++++++ .../AppMap.AspNetCore.csproj | 17 + .../AppMapApplicationBuilderExtensions.cs | 30 ++ .../AppMap.AspNetCore/AppMapHostingStartup.cs | 48 ++ .../src/AppMap.AspNetCore/AppMapMiddleware.cs | 140 ++++++ .../RemoteRecordingMiddleware.cs | 89 ++++ .../AppMap.Attributes.csproj | 11 + .../src/AppMap.Attributes/LabelsAttribute.cs | 23 + .../AppMap.StartupHook.csproj | 24 + managed/src/AppMap.StartupHook/StartupHook.cs | 34 ++ .../AppMap.SystemWeb/AppMap.SystemWeb.csproj | 21 + .../src/AppMap.SystemWeb/AppMapHttpModule.cs | 285 +++++++++++ .../AppMap.Testing.NUnit.csproj | 22 + .../AppMap.Testing.NUnit/AppMapAttribute.cs | 74 +++ .../AppMap.Testing.Xunit.csproj | 22 + .../AppMap.Testing.Xunit/AppMapAttribute.cs | 62 +++ .../AppMap.Agent.Tests.csproj | 27 + .../AppMap.Agent.Tests/AppMapConfigTests.cs | 63 +++ .../AppMapSerializerTests.cs | 140 ++++++ .../AppMap.Agent.Tests/AsyncResultTests.cs | 86 ++++ .../AppMap.Agent.Tests/HostingStartupTests.cs | 44 ++ .../test/AppMap.Agent.Tests/LabelsTests.cs | 138 ++++++ .../RecordingSessionTests.cs | 126 +++++ .../AppMap.Agent.Tests/SourceLocatorTests.cs | 72 +++ .../test/AppMap.Agent.Tests/SqlHooksTests.cs | 25 + managed/test/AppMap.Agent.Tests/ValueTests.cs | 50 ++ 92 files changed, 6857 insertions(+) create mode 100644 managed/.github/workflows/ci.yml create mode 100644 managed/.github/workflows/harness.yml create mode 100644 managed/.gitignore create mode 100644 managed/AppMap.sln create mode 100644 managed/BACKLOG.md create mode 100644 managed/DESIGN.md create mode 100644 managed/README.md create mode 100644 managed/examples/HelloAppMap/HelloAppMap.csproj create mode 100644 managed/examples/HelloAppMap/Program.cs create mode 100644 managed/examples/HelloAppMap/appmap.yml create mode 100644 managed/examples/PetClinic/Controllers/OwnersController.cs create mode 100644 managed/examples/PetClinic/Controllers/VetsController.cs create mode 100644 managed/examples/PetClinic/Data/PetClinicContext.cs create mode 100644 managed/examples/PetClinic/Data/SeedData.cs create mode 100644 managed/examples/PetClinic/Models/Owner.cs create mode 100644 managed/examples/PetClinic/Models/Pet.cs create mode 100644 managed/examples/PetClinic/Models/Vet.cs create mode 100644 managed/examples/PetClinic/PetClinic.csproj create mode 100644 managed/examples/PetClinic/Program.cs create mode 100644 managed/examples/PetClinic/Services/OwnerService.cs create mode 100644 managed/examples/PetClinic/Services/VetService.cs create mode 100644 managed/examples/PetClinic/appmap.yml create mode 100644 managed/examples/PetClinic/docs/README.md create mode 100644 managed/examples/PetClinic/docs/get-owner-by-lastname.sequence.png create mode 100644 managed/examples/PetClinic/docs/get-owners.sequence.png create mode 100644 managed/examples/PetClinic/docs/get-owners.sequence.uml create mode 100644 managed/examples/PetClinic/docs/get-vets-async.sequence.png create mode 100644 managed/examples/PetClinic/docs/get-vets.sequence.png create mode 100644 managed/examples/PetClinic/docs/post-owners.sequence.png create mode 100644 managed/harness/README.md create mode 100644 managed/harness/fixtures/SqlServerWeb/Data.cs create mode 100644 managed/harness/fixtures/SqlServerWeb/Program.cs create mode 100644 managed/harness/fixtures/SqlServerWeb/SqlServerWeb.csproj create mode 100644 managed/harness/fixtures/SystemWebApp/Default.aspx create mode 100644 managed/harness/fixtures/SystemWebApp/appmap.yml create mode 100644 managed/harness/fixtures/SystemWebApp/web.config create mode 100644 managed/harness/fixtures/ZeroTouchWeb/Program.cs create mode 100644 managed/harness/fixtures/ZeroTouchWeb/Widgets.cs create mode 100644 managed/harness/fixtures/ZeroTouchWeb/ZeroTouchWeb.csproj create mode 100644 managed/harness/run.py create mode 100644 managed/harness/targets/eshoponweb.json create mode 100644 managed/harness/targets/sqlserver-web.json create mode 100644 managed/harness/targets/zerotouch-web.json create mode 100644 managed/src/AppMap.Agent/AgentBootstrap.cs create mode 100644 managed/src/AppMap.Agent/AppMap.Agent.csproj create mode 100644 managed/src/AppMap.Agent/Config/AppMapConfig.cs create mode 100644 managed/src/AppMap.Agent/Config/Properties.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/AsyncResult.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/AttributeLabels.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/BuiltinHooks.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/EventTemplateRegistry.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/HookPatcher.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/Instrumentor.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/MethodHooks.cs create mode 100644 managed/src/AppMap.Agent/Instrumentation/SqlHooks.cs create mode 100644 managed/src/AppMap.Agent/Output/CodeObjectTree.cs create mode 100644 managed/src/AppMap.Agent/Output/Event.cs create mode 100644 managed/src/AppMap.Agent/Output/Metadata.cs create mode 100644 managed/src/AppMap.Agent/Output/Value.cs create mode 100644 managed/src/AppMap.Agent/Polyfills.cs create mode 100644 managed/src/AppMap.Agent/Record/AppMapSerializer.cs create mode 100644 managed/src/AppMap.Agent/Record/Recorder.cs create mode 100644 managed/src/AppMap.Agent/Record/Recording.cs create mode 100644 managed/src/AppMap.Agent/Util/GitMetadata.cs create mode 100644 managed/src/AppMap.Agent/Util/Logger.cs create mode 100644 managed/src/AppMap.Agent/Util/SourceLocator.cs create mode 100644 managed/src/AppMap.Agent/Util/WindowsPdbReader.cs create mode 100644 managed/src/AppMap.AspNetCore/AppMap.AspNetCore.csproj create mode 100644 managed/src/AppMap.AspNetCore/AppMapApplicationBuilderExtensions.cs create mode 100644 managed/src/AppMap.AspNetCore/AppMapHostingStartup.cs create mode 100644 managed/src/AppMap.AspNetCore/AppMapMiddleware.cs create mode 100644 managed/src/AppMap.AspNetCore/RemoteRecordingMiddleware.cs create mode 100644 managed/src/AppMap.Attributes/AppMap.Attributes.csproj create mode 100644 managed/src/AppMap.Attributes/LabelsAttribute.cs create mode 100644 managed/src/AppMap.StartupHook/AppMap.StartupHook.csproj create mode 100644 managed/src/AppMap.StartupHook/StartupHook.cs create mode 100644 managed/src/AppMap.SystemWeb/AppMap.SystemWeb.csproj create mode 100644 managed/src/AppMap.SystemWeb/AppMapHttpModule.cs create mode 100644 managed/src/AppMap.Testing.NUnit/AppMap.Testing.NUnit.csproj create mode 100644 managed/src/AppMap.Testing.NUnit/AppMapAttribute.cs create mode 100644 managed/src/AppMap.Testing.Xunit/AppMap.Testing.Xunit.csproj create mode 100644 managed/src/AppMap.Testing.Xunit/AppMapAttribute.cs create mode 100644 managed/test/AppMap.Agent.Tests/AppMap.Agent.Tests.csproj create mode 100644 managed/test/AppMap.Agent.Tests/AppMapConfigTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/AppMapSerializerTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/AsyncResultTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/HostingStartupTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/LabelsTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/RecordingSessionTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/SourceLocatorTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/SqlHooksTests.cs create mode 100644 managed/test/AppMap.Agent.Tests/ValueTests.cs 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 0000000000000000000000000000000000000000..2aca532590679046d3e5466354e76d4d47b11b3b GIT binary patch literal 44163 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 Iv>hJ)2YW!?b^rhX literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9c3c02dc3a1800793a868e18e0742a016cd28a58 GIT binary patch literal 45308 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: 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 0000000000000000000000000000000000000000..fcb8be12847339131f95ed1841ee0b3462daf30c GIT binary patch literal 30802 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 I9=-_pUloA5RR910 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a81aa1486291d7fb31fad68b73254afe3f8c5cb GIT binary patch literal 41253 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 Vq4Nt~zX4|;OLOaA@=RSK{{fE7IN$&P literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..24f95127beab93ed2e40c3909361cac1438d6d9e GIT binary patch literal 37119 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 literal 0 HcmV?d00001 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))); + } +}